diff --git a/NullDepth.slnx b/NullDepth.slnx index dab69e9..eaffa1b 100644 --- a/NullDepth.slnx +++ b/NullDepth.slnx @@ -2,4 +2,7 @@ + + + diff --git a/README.md b/README.md index 1d0a542..ba1ecf2 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,12 @@ Build celej solution: dotnet build NullDepth.slnx -c Release ``` +Automatická kontrola dát a všetkých hlavných herných ciest: + +```powershell +dotnet run --project tests\NullDepth.Validation\NullDepth.Validation.csproj -c Release +``` + Herný obsah upravíte v `src/NullDepth/wwwroot/data/game.json`; slovenské a anglické texty sú v `src/NullDepth/wwwroot/localization`. + +Aktuálny prototyp obsahuje archív nájdených logov, dátovo definovaný inventár, fade a alarm prechody, autosave indikátor, potvrdenie novej hry, mazanie save a migráciu starších uložených dát. diff --git a/specs/NULL_DEPTH_TECHNICAL_ARCHITECTURE.md b/specs/NULL_DEPTH_TECHNICAL_ARCHITECTURE.md index 8f07dde..69c2228 100644 --- a/specs/NULL_DEPTH_TECHNICAL_ARCHITECTURE.md +++ b/specs/NULL_DEPTH_TECHNICAL_ARCHITECTURE.md @@ -7,6 +7,8 @@ - HTML/CSS fullscreen UI - JSON obsah bez backendu a databázy - browser localStorage pre save a settings +- Web Audio/HTML Audio vrstva s procedurálnym fallbackom +- Browser Fullscreen API ovládané zo Settings ## Princíp @@ -24,13 +26,16 @@ Input / kliknutie ```text Game/GameService.cs načítanie dát, pohyb, flagy, save +Game/AudioService.cs ambienty, SFX a live master volume Models/GameModels.cs dátové modely Pages/Home.razor fullscreen obrazovky a overlaye +wwwroot/js/null-depth.js audio runtime a fullscreen interop wwwroot/data/game.json celý level a gameplay obsah wwwroot/localization/sk.json slovenské texty wwwroot/localization/en.json anglické texty wwwroot/assets/images/... title a miestnosti wwwroot/assets/audio/... ambience a SFX sloty +tests/NullDepth.Validation offline dátové a gameplay testy ``` ## RoomDefinition @@ -42,13 +47,22 @@ Interakcia môže: - vyžadovať alebo zakazovať flagy - nastaviť flagy - pridať predmet +- uložiť nájdený záznam do trvalého archívu - vytvoriť checkpoint - ponúknuť dátové voľby - spustiť smrť alebo ending ## GameState -Ukladá aktuálnu miestnosť, navštívené miestnosti, flagy, inventár, checkpoint a čas uloženia. Save používa kľúč `null-depth.save.v1`; settings používajú `null-depth.settings.v1`. +Ukladá aktuálnu miestnosť, navštívené miestnosti, flagy, inventár, archív logov, checkpoint, dokončený ending a čas uloženia. Save používa kľúč `null-depth.save.v1`, ale vnútorná verzia stavu je `3` a starší stav sa automaticky migruje. Settings používajú `null-depth.settings.v1`. + +Nová hra pred prepísaním vyžaduje potvrdenie. Save možno vymazať v Settings. Poškodený alebo budúcou verziou nekompatibilný stav sa nikdy automaticky neprepíše; UI ponúkne jeho explicitné odstránenie. + +Ak je v miestnosti viac dostupných interakcií, `E` otvorí dátovo naplnený picker. Ovláda sa šípkami, Enterom, číslicami alebo myšou. + +Pohyb používa krátky fade, dôležité systémové zásahy alarmové bliknutie a text miestnosti jemný reveal. Každý zápis stavu vyvolá viditeľný autosave indikátor. + +`game.json` definuje aj `items`, `initialItems` a `archive`. Interakcia s `archiveId` pridá záznam do hráčovho archívu a ten zostáva dostupný cez `L` aj po zmiznutí jednorazovej interakcie. ## Rozšírenie hry diff --git a/src/NullDepth/Game/AudioService.cs b/src/NullDepth/Game/AudioService.cs new file mode 100644 index 0000000..085cede --- /dev/null +++ b/src/NullDepth/Game/AudioService.cs @@ -0,0 +1,21 @@ +using Microsoft.JSInterop; + +namespace NullDepth.Game; + +public sealed class AudioService(IJSRuntime js) +{ + public async ValueTask InitializeAsync(int volume) => + await js.InvokeVoidAsync("nullDepth.audio.initialize", volume); + + public async ValueTask SetVolumeAsync(int volume) => + await js.InvokeVoidAsync("nullDepth.audio.setMasterVolume", volume); + + public async ValueTask PlayAmbientAsync(string? source) => + await js.InvokeVoidAsync("nullDepth.audio.playAmbient", source); + + public async ValueTask PlaySfxAsync(string? source) => + await js.InvokeVoidAsync("nullDepth.audio.playSfx", source); + + public async ValueTask StopAsync() => + await js.InvokeVoidAsync("nullDepth.audio.stopAll"); +} diff --git a/src/NullDepth/Game/GameService.cs b/src/NullDepth/Game/GameService.cs index 0d7ad58..f11da73 100644 --- a/src/NullDepth/Game/GameService.cs +++ b/src/NullDepth/Game/GameService.cs @@ -7,6 +7,7 @@ namespace NullDepth.Game; public sealed class GameService(HttpClient http, IJSRuntime js) { + private const int CurrentSaveVersion = 3; private const string SaveKey = "null-depth.save.v1"; private const string SettingsKey = "null-depth.settings.v1"; private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web); @@ -16,6 +17,8 @@ public sealed class GameService(HttpClient http, IJSRuntime js) public GameState State { get; private set; } = new(); public PlayerSettings Settings { get; private set; } = new(); public bool HasSave { get; private set; } + public SaveLoadResult LastLoadResult { get; private set; } = SaveLoadResult.Missing; + public event Action? Saved; public async Task InitializeAsync() { @@ -25,12 +28,16 @@ public sealed class GameService(HttpClient http, IJSRuntime js) Language = await js.InvokeAsync("nullDepth.browserLanguage") }; await SetLanguageAsync(Settings.Language, persist: false); - HasSave = !string.IsNullOrWhiteSpace(await js.InvokeAsync("localStorage.getItem", SaveKey)); + var rawSave = await js.InvokeAsync("localStorage.getItem", SaveKey); + HasSave = !string.IsNullOrWhiteSpace(rawSave); + LastLoadResult = InspectSave(rawSave); } public string T(string key) => _texts.TryGetValue(key, out var text) ? text : key; public RoomDefinition CurrentRoom => Data.Rooms.First(r => r.Id == State.CurrentRoomId); public RoomDefinition? FindRoom(string id) => Data.Rooms.FirstOrDefault(r => r.Id == id); + public ItemDefinition? FindItem(string id) => Data.Items.FirstOrDefault(i => i.Id == id); + public ArchiveDefinition? FindArchive(string id) => Data.Archive.FirstOrDefault(a => a.Id == id); public bool HasAll(IEnumerable flags) => flags.All(State.Flags.Contains); public async Task SetLanguageAsync(string language, bool persist = true) @@ -42,20 +49,92 @@ public sealed class GameService(HttpClient http, IJSRuntime js) public async Task NewGameAsync() { - State = new GameState { CurrentRoomId = Data.StartRoomId }; + State = new GameState + { + CurrentRoomId = Data.StartRoomId, + ContentVersion = Data.Version, + Inventory = [.. Data.InitialItems] + }; State.VisitedRooms.Add(State.CurrentRoomId); SetCheckpoint(); await SaveAsync(); } - public async Task ContinueAsync() + public async Task ContinueAsync() { - var loaded = await LoadValueAsync(SaveKey); - if (loaded is null || FindRoom(loaded.CurrentRoomId) is null) return false; + var raw = await js.InvokeAsync("localStorage.getItem", SaveKey); + if (string.IsNullOrWhiteSpace(raw)) return LastLoadResult = SaveLoadResult.Missing; + + GameState? loaded; + try { loaded = JsonSerializer.Deserialize(raw, _json); } + catch { return LastLoadResult = SaveLoadResult.Corrupt; } + if (loaded is null) return LastLoadResult = SaveLoadResult.Corrupt; + if (loaded.Version > CurrentSaveVersion || loaded.ContentVersion > Data.Version) + return LastLoadResult = SaveLoadResult.Incompatible; + + var migrated = loaded.Version < CurrentSaveVersion || loaded.ContentVersion < Data.Version; + Migrate(loaded); State = loaded; State.VisitedRooms.Add(State.CurrentRoomId); - HasSave = true; - return true; + if (migrated) await SaveAsync(); + else HasSave = true; + return LastLoadResult = SaveLoadResult.Success; + } + + private void Migrate(GameState state) + { + state.VisitedRooms ??= []; + state.Flags ??= []; + state.Inventory ??= []; + state.Archive ??= []; + + if (state.Version < 2) + { + if (state.Flags.Contains("elevator_cycle_complete")) state.Flags.Remove("elevator_stalled"); + if (state.Flags.Contains("core_shutdown")) state.CompletedEnding = "shutdown"; + else if (state.Flags.Contains("external_network_restored")) state.CompletedEnding = "release"; + else if (state.Flags.Contains("containment_active")) state.CompletedEnding = "containment"; + } + + if (state.Version < 3) + { + foreach (var archive in Data.Archive.Where(a => state.Flags.Contains($"{a.Id}_found") || + (a.Id == "containment_record" && state.Flags.Contains("containment_seen")))) + if (!state.Archive.Contains(archive.Id)) state.Archive.Add(archive.Id); + foreach (var item in Data.InitialItems) + if (!state.Inventory.Contains(item)) state.Inventory.Add(item); + + if (state.Checkpoint is not null) + { + state.Checkpoint.VisitedRooms ??= []; + state.Checkpoint.Flags ??= []; + state.Checkpoint.Inventory ??= []; + state.Checkpoint.Archive ??= []; + foreach (var item in Data.InitialItems) + if (!state.Checkpoint.Inventory.Contains(item)) state.Checkpoint.Inventory.Add(item); + foreach (var entry in Data.Archive.Where(a => state.Checkpoint.Flags.Contains($"{a.Id}_found") || + (a.Id == "containment_record" && state.Checkpoint.Flags.Contains("containment_seen")))) + if (!state.Checkpoint.Archive.Contains(entry.Id)) state.Checkpoint.Archive.Add(entry.Id); + } + } + + if (FindRoom(state.CurrentRoomId) is null) state.CurrentRoomId = Data.StartRoomId; + state.VisitedRooms.RemoveWhere(id => FindRoom(id) is null); + state.Inventory = state.Inventory.Where(id => FindItem(id) is not null).Distinct().ToList(); + state.Archive = state.Archive.Where(id => FindArchive(id) is not null).Distinct().ToList(); + if (state.Checkpoint is not null) + { + if (FindRoom(state.Checkpoint.RoomId) is null) state.Checkpoint.RoomId = Data.StartRoomId; + state.Checkpoint.VisitedRooms ??= []; + state.Checkpoint.Flags ??= []; + state.Checkpoint.Inventory ??= []; + state.Checkpoint.Archive ??= []; + state.Checkpoint.VisitedRooms.RemoveWhere(id => FindRoom(id) is null); + state.Checkpoint.Inventory = state.Checkpoint.Inventory.Where(id => FindItem(id) is not null).Distinct().ToList(); + state.Checkpoint.Archive = state.Checkpoint.Archive.Where(id => FindArchive(id) is not null).Distinct().ToList(); + } + state.Version = CurrentSaveVersion; + state.ContentVersion = Data.Version; } public async Task MoveAsync(string direction) @@ -73,14 +152,17 @@ public sealed class GameService(HttpClient http, IJSRuntime js) public async Task ApplyInteractionAsync(InteractionDefinition interaction) { - Apply(interaction.SetFlags, interaction.AddItems); + Apply(interaction.SetFlags, interaction.ClearFlags, interaction.AddItems); + if (!string.IsNullOrWhiteSpace(interaction.ArchiveId) && !State.Archive.Contains(interaction.ArchiveId)) + State.Archive.Add(interaction.ArchiveId); if (interaction.Checkpoint) SetCheckpoint(); await SaveAsync(); } public async Task ApplyChoiceAsync(ChoiceDefinition choice) { - Apply(choice.SetFlags, choice.AddItems); + Apply(choice.SetFlags, choice.ClearFlags, choice.AddItems); + if (!string.IsNullOrWhiteSpace(choice.Ending)) State.CompletedEnding = choice.Ending; if (choice.Checkpoint) SetCheckpoint(); await SaveAsync(); } @@ -93,6 +175,8 @@ public sealed class GameService(HttpClient http, IJSRuntime js) State.VisitedRooms = [.. checkpoint.VisitedRooms]; State.Flags = [.. checkpoint.Flags]; State.Inventory = [.. checkpoint.Inventory]; + State.Archive = [.. checkpoint.Archive]; + State.CompletedEnding = null; await SaveAsync(); } @@ -108,10 +192,21 @@ public sealed class GameService(HttpClient http, IJSRuntime js) State.SavedAt = DateTimeOffset.UtcNow; await StoreValueAsync(SaveKey, State); HasSave = true; + LastLoadResult = SaveLoadResult.Success; + Saved?.Invoke(); } - private void Apply(IEnumerable flags, IEnumerable items) + public async Task DeleteSaveAsync() { + await js.InvokeVoidAsync("localStorage.removeItem", SaveKey); + State = new GameState { CurrentRoomId = Data.StartRoomId, ContentVersion = Data.Version }; + HasSave = false; + LastLoadResult = SaveLoadResult.Missing; + } + + private void Apply(IEnumerable flags, IEnumerable clearFlags, IEnumerable items) + { + foreach (var flag in clearFlags) State.Flags.Remove(flag); foreach (var flag in flags) State.Flags.Add(flag); foreach (var item in items) if (!State.Inventory.Contains(item)) State.Inventory.Add(item); } @@ -121,9 +216,24 @@ public sealed class GameService(HttpClient http, IJSRuntime js) RoomId = State.CurrentRoomId, VisitedRooms = [.. State.VisitedRooms], Flags = [.. State.Flags], - Inventory = [.. State.Inventory] + Inventory = [.. State.Inventory], + Archive = [.. State.Archive] }; + private SaveLoadResult InspectSave(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return SaveLoadResult.Missing; + try + { + var save = JsonSerializer.Deserialize(raw, _json); + if (save is null) return SaveLoadResult.Corrupt; + return save.Version > CurrentSaveVersion || save.ContentVersion > Data.Version + ? SaveLoadResult.Incompatible + : SaveLoadResult.Success; + } + catch { return SaveLoadResult.Corrupt; } + } + private async Task LoadValueAsync(string key) { var raw = await js.InvokeAsync("localStorage.getItem", key); diff --git a/src/NullDepth/Models/GameModels.cs b/src/NullDepth/Models/GameModels.cs index ae268d9..76cd5e9 100644 --- a/src/NullDepth/Models/GameModels.cs +++ b/src/NullDepth/Models/GameModels.cs @@ -2,16 +2,36 @@ namespace NullDepth.Models; public sealed class GameData { + public int Version { get; set; } = 1; public string StartRoomId { get; set; } = "loc_01_exterior_access"; + public List InitialItems { get; set; } = []; + public List Items { get; set; } = []; + public List Archive { get; set; } = []; public List Rooms { get; set; } = []; } +public sealed class ItemDefinition +{ + public string Id { get; set; } = ""; + public string NameKey { get; set; } = ""; + public string DescriptionKey { get; set; } = ""; + public string Category { get; set; } = "equipment"; +} + +public sealed class ArchiveDefinition +{ + public string Id { get; set; } = ""; + public string TitleKey { get; set; } = ""; + public string ContentKey { get; set; } = ""; +} + public sealed class RoomDefinition { public string Id { get; set; } = ""; public string NameKey { get; set; } = ""; public string DescriptionKey { get; set; } = ""; public string Image { get; set; } = ""; + public string? Ambient { get; set; } public string? AlternateImage { get; set; } public string? AlternateImageFlag { get; set; } public int X { get; set; } @@ -36,7 +56,10 @@ public sealed class InteractionDefinition public List RequiresFlags { get; set; } = []; public List ForbiddenFlags { get; set; } = []; public List SetFlags { get; set; } = []; + public List ClearFlags { get; set; } = []; public List AddItems { get; set; } = []; + public string? ArchiveId { get; set; } + public string? Sound { get; set; } public bool Checkpoint { get; set; } public List Choices { get; set; } = []; } @@ -47,7 +70,9 @@ public sealed class ChoiceDefinition public string? ResultKey { get; set; } public List RequiresFlags { get; set; } = []; public List SetFlags { get; set; } = []; + public List ClearFlags { get; set; } = []; public List AddItems { get; set; } = []; + public string? Sound { get; set; } public string? Ending { get; set; } public bool Death { get; set; } public bool Checkpoint { get; set; } @@ -55,11 +80,14 @@ public sealed class ChoiceDefinition public sealed class GameState { - public int Version { get; set; } = 1; + public int Version { get; set; } = 3; + public int ContentVersion { get; set; } = 1; public string CurrentRoomId { get; set; } = "loc_01_exterior_access"; public HashSet VisitedRooms { get; set; } = []; public HashSet Flags { get; set; } = []; public List Inventory { get; set; } = []; + public List Archive { get; set; } = []; + public string? CompletedEnding { get; set; } public GameSnapshot? Checkpoint { get; set; } public DateTimeOffset SavedAt { get; set; } = DateTimeOffset.UtcNow; } @@ -70,6 +98,7 @@ public sealed class GameSnapshot public HashSet VisitedRooms { get; set; } = []; public HashSet Flags { get; set; } = []; public List Inventory { get; set; } = []; + public List Archive { get; set; } = []; } public sealed class PlayerSettings @@ -77,3 +106,11 @@ public sealed class PlayerSettings public string Language { get; set; } = "en"; public int MasterVolume { get; set; } = 70; } + +public enum SaveLoadResult +{ + Success, + Missing, + Corrupt, + Incompatible +} diff --git a/src/NullDepth/Pages/Home.razor b/src/NullDepth/Pages/Home.razor index de3d6d7..e3690a0 100644 --- a/src/NullDepth/Pages/Home.razor +++ b/src/NullDepth/Pages/Home.razor @@ -1,9 +1,12 @@ @page "/" @inject GameService Game +@inject AudioService Audio +@inject IJSRuntime JS +@implements IDisposable NULL DEPTH -
+
@if (!_ready) {
NULL DEPTH // BOOTING
@@ -16,8 +19,9 @@ } @@ -44,9 +48,9 @@ var room = Game.CurrentRoom;
-
NULL DEPTH@Game.T("game.autosave")
+
NULL DEPTH@Game.T(_savePulse ? "game.autosaveSaving" : "game.autosaveSaved")
-

@room.Id.Replace("_", " // ").ToUpperInvariant()

@Game.T(room.NameKey)

@Game.T(room.DescriptionKey)

+

@room.Id.Replace("_", " // ").ToUpperInvariant()

@Game.T(room.NameKey)

@Game.T(room.DescriptionKey)

@if (!string.IsNullOrWhiteSpace(_notice)) {

@_notice

}
@foreach (var action in Game.AvailableInteractions()) { }
@@ -57,8 +61,8 @@
- - + +
} @@ -68,7 +72,23 @@ } else if (_screen == Screen.Ending) { -

ENDING // @_ending?.ToUpperInvariant()

@Game.T($"ending.{_ending}.title")

@Game.T($"ending.{_ending}.body")

+

ENDING // @_ending?.ToUpperInvariant()

@Game.T($"ending.{_ending}.title")

@Game.T($"ending.{_ending}.body")

+ } + + @if (_showInteractionPicker) + { + } @if (_interaction is not null) @@ -87,19 +107,39 @@ } @if (_showInventory) { - + + } + @if (_showArchive) + { + } @if (_showPause) { - + } @if (_showSettings) { } + @if (_confirmation is not null) + { + + } + @if (_saveError is not null) + { + + } + +
@code { @@ -112,15 +152,50 @@ private InteractionDefinition? _interaction; private bool _showMap; private bool _showInventory; + private bool _showArchive; private bool _showPause; private bool _showSettings; + private bool _showInteractionPicker; + private bool _isFullscreen; + private bool _isTransitioning; + private bool _systemFlash; + private bool _savePulse; + private int _savePulseVersion; + private int _interactionIndex; + private string? _titleNotice; + private SaveLoadResult? _saveError; + private Confirmation? _confirmation; + private List AvailableActions => Game.AvailableInteractions().ToList(); + private string EndingImageStyle => $"background-image:url('{Game.RoomImage(Game.CurrentRoom)}')"; - protected override async Task OnInitializedAsync() { await Game.InitializeAsync(); _ready = true; } - protected override async Task OnAfterRenderAsync(bool firstRender) { if (_ready) await _shell.FocusAsync(); } - private async Task StartNewGame() { await Game.NewGameAsync(); _screen = Screen.Briefing; _notice = null; } - private async Task ContinueGame() { if (await Game.ContinueAsync()) _screen = Screen.Game; } - private void BeginPlay() => _screen = Screen.Game; - private async Task Move(string direction) { var result = await Game.MoveAsync(direction); if (result is not null) _notice = result; if (result == "") _notice = null; } + protected override async Task OnInitializedAsync() { await Game.InitializeAsync(); Game.Saved += OnSaved; await Audio.InitializeAsync(Game.Settings.MasterVolume); _ready = true; } + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender && _ready) await _shell.FocusAsync(); } + private Task StartNewGame() { if (Game.HasSave) { _confirmation = Confirmation.NewGame; return Task.CompletedTask; } return StartNewGameConfirmed(); } + private async Task StartNewGameConfirmed() { _confirmation = null; _titleNotice = null; await Audio.PlaySfxAsync("procedural:ui_confirm"); await Game.NewGameAsync(); _screen = Screen.Briefing; _notice = null; } + private async Task ContinueGame() + { + var result = await Game.ContinueAsync(); + if (result != SaveLoadResult.Success) { _saveError = result; return; } + _titleNotice = null; + _ending = Game.State.CompletedEnding; + _screen = _ending is null ? Screen.Game : Screen.Ending; + await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient); + } + private async Task BeginPlay() { _screen = Screen.Game; await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient); } + private async Task Move(string direction) + { + if (_isTransitioning) return; + if (!Game.CurrentRoom.Exits.TryGetValue(direction, out var exit)) return; + if (!Game.HasAll(exit.RequiresFlags)) { _notice = Game.T(exit.LockedMessageKey ?? "game.exit.locked"); return; } + _isTransitioning = true; + await Audio.PlaySfxAsync("procedural:door_heavy"); + await Task.Delay(150); + var result = await Game.MoveAsync(direction); + if (result is not null) _notice = result; + if (result == "") { _notice = null; await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient); } + await Task.Delay(260); + _isTransitioning = false; + } private Task MoveNorth() => Move("north"); private Task MoveSouth() => Move("south"); private Task MoveWest() => Move("west"); @@ -129,39 +204,118 @@ private bool HasSouth => Game.CurrentRoom.Exits.ContainsKey("south"); private bool HasWest => Game.CurrentRoom.Exits.ContainsKey("west"); private bool HasEast => Game.CurrentRoom.Exits.ContainsKey("east"); - private void OpenFirstInteraction() { var first = Game.AvailableInteractions().FirstOrDefault(); if (first is not null) OpenInteraction(first); } - private void OpenInteraction(InteractionDefinition interaction) { _interaction = interaction; _notice = null; } - private async Task ResolveInteraction() { if (_interaction is null) return; await Game.ApplyInteractionAsync(_interaction); _notice = Game.T(_interaction.ContentKey); _interaction = null; } - private async Task ResolveChoice(ChoiceDefinition choice) { await Game.ApplyChoiceAsync(choice); _interaction = null; if (choice.Death) { _screen = Screen.Death; return; } if (!string.IsNullOrWhiteSpace(choice.Ending)) { _ending = choice.Ending; _screen = Screen.Ending; return; } _notice = choice.ResultKey is null ? null : Game.T(choice.ResultKey); } - private async Task RestoreCheckpoint() { await Game.RestoreCheckpointAsync(); _screen = Screen.Game; _notice = Game.T("death.restored"); } - private void ReturnToTitle() { CloseOverlays(); _showSettings = false; _screen = Screen.Title; _menuIndex = 0; } + private void OpenInteractionSelector() + { + var actions = AvailableActions; + if (actions.Count == 0) { _notice = Game.T("interaction.none"); return; } + if (actions.Count == 1) { OpenInteraction(actions[0]); return; } + _interactionIndex = 0; + _showInteractionPicker = true; + _notice = null; + } + private void OpenInteraction(InteractionDefinition interaction) { _showInteractionPicker = false; _interaction = interaction; _notice = null; } + private void SelectInteraction(InteractionDefinition interaction) => OpenInteraction(interaction); + private async Task ResolveInteraction() + { + if (_interaction is null) return; + var resolved = _interaction; + await Game.ApplyInteractionAsync(resolved); + await Audio.PlaySfxAsync(resolved.Sound); + _interaction = null; + if (resolved.SetFlags.Contains("power_restored") || resolved.Sound == "procedural:alarm_pulse") _ = TriggerSystemFlashAsync(); + await Task.Delay(180); + _notice = Game.T(resolved.ContentKey); + } + private async Task ResolveChoice(ChoiceDefinition choice) + { + await Game.ApplyChoiceAsync(choice); + await Audio.PlaySfxAsync(choice.Sound); + _interaction = null; + if (choice.Sound == "procedural:alarm_pulse") _ = TriggerSystemFlashAsync(); + if (choice.Death) { _screen = Screen.Death; return; } + if (!string.IsNullOrWhiteSpace(choice.Ending)) { _ending = choice.Ending; _screen = Screen.Ending; return; } + await Task.Delay(180); + _notice = choice.ResultKey is null ? null : Game.T(choice.ResultKey); + } + private async Task RestoreCheckpoint() { await Game.RestoreCheckpointAsync(); _screen = Screen.Game; _notice = Game.T("death.restored"); await Audio.PlayAmbientAsync(Game.CurrentRoom.Ambient); } + private async Task ReturnToTitle() { CloseOverlays(); _showSettings = false; _screen = Screen.Title; _menuIndex = 0; _titleNotice = null; await Audio.StopAsync(); } private async Task ChangeLanguage(string language) { await Game.SetLanguageAsync(language); StateHasChanged(); } private Task ChangeToSlovak() => ChangeLanguage("sk"); private Task ChangeToEnglish() => ChangeLanguage("en"); - private async Task SaveSettings(ChangeEventArgs _) => await Game.SaveSettingsAsync(); + private async Task ChangeVolume(ChangeEventArgs args) { if (!int.TryParse(args.Value?.ToString(), out var volume)) return; Game.Settings.MasterVolume = volume; await Audio.SetVolumeAsync(volume); await Game.SaveSettingsAsync(); } + private async Task OpenSettings() { _isFullscreen = await JS.InvokeAsync("nullDepth.isFullscreen"); _showSettings = true; } + private async Task ToggleFullscreen() { _isFullscreen = await JS.InvokeAsync("nullDepth.toggleFullscreen"); } private async Task CloseSettings() { await Game.SaveSettingsAsync(); _showSettings = false; } - private void CloseOverlays() { _interaction = null; _showMap = false; _showInventory = false; _showPause = false; } + private void CloseOverlays() { _interaction = null; _showInteractionPicker = false; _showMap = false; _showInventory = false; _showArchive = false; _showPause = false; } + private void RequestDeleteSave() => _confirmation = Confirmation.DeleteSave; + private async Task ConfirmPendingAction() + { + if (_confirmation == Confirmation.NewGame) await StartNewGameConfirmed(); + else if (_confirmation == Confirmation.DeleteSave) + { + await Game.DeleteSaveAsync(); + _confirmation = null; + CloseOverlays(); + _showSettings = false; + _screen = Screen.Title; + _menuIndex = 0; + _titleNotice = Game.T("save.deleted"); + await Audio.StopAsync(); + } + } + private void CancelConfirmation() => _confirmation = null; + private async Task DeleteBrokenSave() { await Game.DeleteSaveAsync(); _saveError = null; _titleNotice = Game.T("save.deleted"); } + private void CloseSaveError() => _saveError = null; + private void OnSaved() => _ = InvokeAsync(PulseSaveAsync); + private async Task PulseSaveAsync() + { + var version = ++_savePulseVersion; + _savePulse = true; + StateHasChanged(); + await Task.Delay(900); + if (version == _savePulseVersion) { _savePulse = false; StateHasChanged(); } + } + private async Task TriggerSystemFlashAsync() + { + _systemFlash = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(650); + _systemFlash = false; + await InvokeAsync(StateHasChanged); + } private string MenuClass(int index) => _menuIndex == index ? "selected" : ""; private async Task HandleKey(KeyboardEventArgs args) { if (!_ready) return; + if (_confirmation is not null || _saveError is not null) { if (args.Key == "Escape") { _confirmation = null; _saveError = null; } return; } if (_showSettings) { if (args.Key == "Escape") await CloseSettings(); return; } - if (_interaction is not null || _showMap || _showInventory) { if (args.Key == "Escape") CloseOverlays(); return; } + if (_showInteractionPicker) + { + var actions = AvailableActions; + if (args.Key == "Escape") CloseOverlays(); + else if (args.Key is "ArrowDown" or "ArrowUp") { var delta = args.Key == "ArrowDown" ? 1 : -1; _interactionIndex = (_interactionIndex + delta + actions.Count) % actions.Count; } + else if (args.Key == "Enter" && actions.Count > 0) OpenInteraction(actions[_interactionIndex]); + else if (int.TryParse(args.Key, out var number) && number > 0 && number <= actions.Count) OpenInteraction(actions[number - 1]); + return; + } + if (_interaction is not null || _showMap || _showInventory || _showArchive) { if (args.Key == "Escape") CloseOverlays(); return; } if (_showPause) { if (args.Key == "Escape") CloseOverlays(); return; } if (_screen == Screen.Title) { if (args.Key is "ArrowDown" or "ArrowUp") { var delta = args.Key == "ArrowDown" ? 1 : -1; do { _menuIndex = (_menuIndex + delta + 3) % 3; } while (_menuIndex == 1 && !Game.HasSave); } - else if (args.Key == "Enter") { if (_menuIndex == 0) await StartNewGame(); else if (_menuIndex == 1) await ContinueGame(); else _showSettings = true; } + else if (args.Key == "Enter") { if (_menuIndex == 0) await StartNewGame(); else if (_menuIndex == 1) await ContinueGame(); else await OpenSettings(); } return; } - if (_screen == Screen.Briefing && args.Key == "Enter") { BeginPlay(); return; } + if (_screen == Screen.Briefing && args.Key == "Enter") { await BeginPlay(); return; } if (_screen != Screen.Game) return; switch (args.Key.ToLowerInvariant()) { case "arrowup": await Move("north"); break; case "arrowdown": await Move("south"); break; case "arrowleft": await Move("west"); break; case "arrowright": await Move("east"); break; - case "e": OpenFirstInteraction(); break; case "m": _showMap = true; break; case "i": _showInventory = true; break; case "escape": _showPause = true; break; + case "e": OpenInteractionSelector(); break; case "m": _showMap = true; break; case "i": _showInventory = true; break; case "l": _showArchive = true; break; case "escape": _showPause = true; break; } } + public void Dispose() => Game.Saved -= OnSaved; + private enum Confirmation { NewGame, DeleteSave } private enum Screen { Title, Briefing, Game, Death, Ending } } diff --git a/src/NullDepth/Program.cs b/src/NullDepth/Program.cs index 1aa9536..adc5c68 100644 --- a/src/NullDepth/Program.cs +++ b/src/NullDepth/Program.cs @@ -9,5 +9,6 @@ builder.RootComponents.Add("head::after"); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddScoped(); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/src/NullDepth/README.md b/src/NullDepth/README.md index f4b2ab9..25a0e94 100644 --- a/src/NullDepth/README.md +++ b/src/NullDepth/README.md @@ -26,4 +26,15 @@ Novú miestnosť možno pridať iba do `game.json` a doplniť jej textové kľú - `M`: minimapa - `I`: inventár - `Esc`: zatvorenie overlayu / pauza +- `L`: archív nájdených záznamov - všetky akcie sú zároveň klikateľné + +## Save a validácia + +Nová hra si pred prepísaním postupu vyžiada potvrdenie. Uloženie možno vymazať v Settings; poškodené alebo novšou verziou nekompatibilné dáta zobrazia zrozumiteľnú chybu. Formát save je verzovaný a staršie uloženia sa migrujú. + +Offline validačný projekt kontroluje assety, SK/EN kľúče, prepojenia miestností, tri endingy, smrť/checkpoint a serializáciu stavu: + +```powershell +dotnet run --project tests\NullDepth.Validation\NullDepth.Validation.csproj -c Release +``` diff --git a/src/NullDepth/wwwroot/assets/audio/README.md b/src/NullDepth/wwwroot/assets/audio/README.md index 058376c..d8da751 100644 --- a/src/NullDepth/wwwroot/assets/audio/README.md +++ b/src/NullDepth/wwwroot/assets/audio/README.md @@ -10,4 +10,6 @@ Pripravené názvy pre finálne assety: - `sfx_power_restore.ogg` - `sfx_alarm_pulse.ogg` -Audio vrstva je zámerne voliteľná; hra je plne funkčná aj bez týchto súborov. +Audio vrstva je funkčná aj bez týchto súborov: hodnoty `procedural:*` v `game.json` používajú Web Audio fallback pre ambienty a udalosti. Po dodaní finálnych súborov stačí príslušnú hodnotu `ambient` alebo `sound` v JSON nahradiť napríklad za `assets/audio/amb_facility_loop.ogg`; C# ani Razor kód sa nemení. + +Master volume v Settings ovláda procedurálne aj súborové zvuky okamžite. Pri skrytí stránky sa ambient pozastaví a po návrate pokračuje. diff --git a/src/NullDepth/wwwroot/css/app.css b/src/NullDepth/wwwroot/css/app.css index 754cfc6..f5f6e8d 100644 --- a/src/NullDepth/wwwroot/css/app.css +++ b/src/NullDepth/wwwroot/css/app.css @@ -11,6 +11,12 @@ button:disabled { opacity:.28; cursor:not-allowed; background:transparent; color kbd, .eyebrow, .subtitle, .top-hud, .command-bar, .terminal-copy, small { font-family:'IBM Plex Mono',Consolas,monospace; } kbd { color:var(--cyan); background:none; padding:0; } .game-shell { position:relative; width:100%; height:100%; outline:none; background:#020507; } +.screen-fade, .system-flash { position:absolute; inset:0; z-index:50; pointer-events:none; opacity:0; } +.screen-fade { background:#010304; transition:opacity .15s ease; } +.game-shell.transitioning .screen-fade { opacity:1; transition-duration:.22s; } +.system-flash { z-index:40; background:radial-gradient(circle at 50% 45%,rgba(255,74,80,.35),rgba(125,0,8,.12) 42%,transparent 72%); } +.game-shell.system-alert .system-flash { animation:system-flash .65s steps(2,end); } +@keyframes system-flash { 0%,100% { opacity:0; } 15%,45% { opacity:1; } 30%,65% { opacity:.18; } } .screen-image { position:absolute; inset:0; min-height:100%; background-position:center; background-size:cover; isolation:isolate; } .vignette { position:absolute; inset:0; z-index:-1; background:linear-gradient(90deg,rgba(0,4,6,.87) 0%,rgba(0,4,6,.28) 40%,rgba(0,4,6,.15) 65%,rgba(0,4,6,.64) 100%),linear-gradient(0deg,rgba(0,3,5,.82) 0%,transparent 40%,rgba(0,3,5,.3) 100%); } .loading-screen { height:100%; display:grid; place-items:center; color:var(--cyan); font:500 .8rem 'IBM Plex Mono'; letter-spacing:.35em; animation:pulse 1.6s infinite; } @@ -23,6 +29,7 @@ kbd { color:var(--cyan); background:none; padding:0; } .title-menu button { border:0; border-left:2px solid transparent; text-align:left; padding:.7rem 1rem; font-size:.78rem; } .title-menu button:hover, .title-menu button.selected { background:linear-gradient(90deg,rgba(158,239,255,.95),rgba(158,239,255,.08)); border-left-color:white; } .title-menu small { margin-top:1rem; color:var(--muted); letter-spacing:.12em; } +.title-notice { margin:.65rem 0 0; color:var(--cyan); font:.7rem 'IBM Plex Mono',Consolas,monospace; line-height:1.5; } .terminal-card, .overlay-card { position:relative; border:1px solid var(--line); background:linear-gradient(145deg,rgba(6,17,21,.96),rgba(1,7,10,.94)); box-shadow:0 30px 90px #000, inset 0 0 40px rgba(66,216,244,.025); backdrop-filter:blur(16px); } .briefing-card { position:absolute; left:6vw; top:50%; transform:translateY(-50%); width:min(38rem,88vw); padding:2.2rem; } .briefing-card dl { display:grid; grid-template-columns:10rem 1fr; margin:1.7rem 0; gap:.55rem 1rem; font-family:'IBM Plex Mono'; font-size:.78rem; } @@ -30,9 +37,13 @@ kbd { color:var(--cyan); background:none; padding:0; } .briefing-card>p:not(.eyebrow) { color:#c4d0d2; line-height:1.7; max-width:34rem; } .primary-action { color:#031013; background:var(--cyan); border-color:var(--cyan); } .top-hud { position:absolute; top:0; left:0; right:0; display:flex; justify-content:space-between; padding:1.25rem 1.6rem; color:rgba(220,248,250,.65); font-size:.62rem; letter-spacing:.18em; background:linear-gradient(#020608b8,transparent); } +.autosave-state { transition:color .2s,text-shadow .2s; }.autosave-state.saving { color:var(--cyan); text-shadow:0 0 12px rgba(66,216,244,.85); animation:save-pulse .45s ease infinite alternate; } +@keyframes save-pulse { to { opacity:.45; } } .room-panel { position:absolute; left:2rem; bottom:5.8rem; width:min(36rem,calc(100vw - 11rem)); padding:1.5rem 1.6rem; background:linear-gradient(90deg,rgba(2,8,11,.93),rgba(2,8,11,.55),transparent); text-shadow:0 2px 8px #000; } .room-panel h2, .overlay-card h2, .terminal-card h2 { margin:.15rem 0 .65rem; font-weight:400; letter-spacing:.11em; } .room-panel>p:not(.eyebrow) { max-width:33rem; margin:.35rem 0; color:#c6d2d4; line-height:1.5; } +.reveal-copy { animation:copy-reveal .65s ease both; } +@keyframes copy-reveal { from { opacity:0; transform:translateY(.35rem); filter:blur(2px); } to { opacity:1; transform:none; filter:none; } } .room-panel .notice { color:var(--cyan)!important; font-family:'IBM Plex Mono'; font-size:.76rem; white-space:pre-line; } .interaction-list { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:1rem; }.interaction-list button { padding:.55rem .7rem; font-size:.68rem; } .movement-pad { position:absolute; right:2rem; bottom:6.2rem; display:grid; grid-template:repeat(3,2.7rem)/repeat(3,2.7rem); gap:.25rem; } @@ -42,10 +53,17 @@ kbd { color:var(--cyan); background:none; padding:0; } .interaction-modal, .overlay-card { width:min(43rem,92vw); max-height:86vh; overflow:auto; padding:2rem; }.terminal-copy { white-space:pre-line; color:#bfe6ea; line-height:1.75; } .close-button { position:absolute; top:.65rem; right:.65rem; border:0; font-size:1.3rem; padding:.35rem .6rem; } .choice-list { display:flex; flex-direction:column; gap:.6rem; margin-top:1.5rem; }.choice-list button { text-align:left; } +.action-picker small { display:block; margin-top:1.25rem; color:var(--muted); letter-spacing:.12em; } .map-card { width:min(62rem,94vw); }.map-grid { display:grid; grid-template-columns:repeat(9,minmax(2.5rem,1fr)); grid-template-rows:repeat(4,3.6rem); gap:.35rem; margin:2rem 0; } .map-cell { display:grid; place-items:center; border:1px solid rgba(158,239,255,.1); color:#44565a; font:1rem 'IBM Plex Mono'; }.map-cell.visited { color:var(--cyan); background:rgba(65,213,240,.06); border-color:var(--line); }.map-cell.current { color:#021014; background:var(--cyan); box-shadow:0 0 24px rgba(66,216,244,.4); } -.inventory-card ul { list-style:none; margin:1.5rem 0 0; padding:0; }.inventory-card li { padding:1rem; border:1px solid var(--line); color:var(--cyan); } +.inventory-card ul { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.65rem; list-style:none; margin:1.5rem 0 0; padding:0; }.inventory-card li { padding:1rem; border:1px solid var(--line); }.inventory-card li small { display:block; color:var(--muted); font-size:.58rem; letter-spacing:.16em; }.inventory-card li strong { display:block; margin:.3rem 0; color:var(--cyan); font-weight:500; letter-spacing:.06em; }.inventory-card li p { margin:.25rem 0 0; color:#b8c8cb; font-size:.78rem; line-height:1.5; } +.archive-list { display:flex; flex-direction:column; gap:.55rem; margin-top:1.5rem; }.archive-list details { border:1px solid var(--line); background:rgba(0,6,8,.45); }.archive-list summary { padding:1rem; color:var(--cyan); cursor:pointer; letter-spacing:.07em; }.archive-list details p { margin:0; padding:0 1rem 1rem; } .pause-card, .settings-card { display:flex; flex-direction:column; gap:.75rem; width:min(29rem,92vw); }.settings-card label { margin-top:.5rem; color:var(--muted); font-size:.76rem; letter-spacing:.09em; }.segmented-control { display:grid; grid-template-columns:1fr 1fr; gap:.4rem; }.settings-card input { accent-color:var(--cyan-strong); } +.setting-toggle.active { color:#031013; background:var(--cyan); border-color:var(--cyan); } +.danger-action { color:#ff9da2; border-color:rgba(255,77,87,.55); }.danger-action:hover, .danger-action:focus-visible { color:white; background:#a51f29; border-color:#ff5e68; } +.confirmation-card { width:min(34rem,92vw); }.confirmation-card>p:not(.eyebrow) { color:#bdcacc; line-height:1.65; }.confirmation-actions { display:flex; justify-content:flex-end; gap:.65rem; margin-top:1.5rem; } .outcome-screen { height:100%; display:flex; flex-direction:column; align-items:flex-start; justify-content:center; padding:10vw; background:radial-gradient(circle at 70% 40%,#11242b,#020507 55%); }.outcome-screen h1 { margin:.2rem 0; font-size:clamp(3rem,8vw,7rem); font-weight:300; letter-spacing:.17em; }.outcome-screen>p:not(.eyebrow) { max-width:44rem; color:#b8c8cb; line-height:1.8; }.outcome-screen button { margin:.35rem .35rem .35rem 0; }.death-screen { background:radial-gradient(circle at 70% 40%,#351014,#050203 55%); }.death-screen .eyebrow { color:var(--danger); } #blazor-error-ui { display:none; position:fixed; inset:auto 0 0; z-index:100; padding:1rem; background:#8b1e24; color:white; } @media(max-width:720px) { .title-card { left:1.5rem;top:8vh; }.title-card h1 { font-size:2.7rem;letter-spacing:.12em; }.title-menu { left:1.5rem;bottom:5rem; }.room-panel { left:0;bottom:5.4rem;width:100%;padding:1.2rem;background:rgba(2,8,11,.82); }.movement-pad { right:1rem;bottom:15rem; }.command-bar { overflow-x:auto;justify-content:flex-start; }.command-bar button { white-space:nowrap; }.map-grid { grid-template-columns:repeat(9,2.3rem);overflow:auto; }.briefing-card { left:1rem;width:calc(100vw - 2rem);padding:1.4rem; }.briefing-card dl { grid-template-columns:1fr; }.briefing-card dd { margin-bottom:.5rem; } } +@media(max-width:720px) { .inventory-card ul { grid-template-columns:1fr; } } +@media(prefers-reduced-motion:reduce) { *, *::before, *::after { animation-duration:.01ms!important; animation-iteration-count:1!important; transition-duration:.01ms!important; } } diff --git a/src/NullDepth/wwwroot/data/game.json b/src/NullDepth/wwwroot/data/game.json index 7210c27..90ccd10 100644 --- a/src/NullDepth/wwwroot/data/game.json +++ b/src/NullDepth/wwwroot/data/game.json @@ -1,22 +1,37 @@ { + "version": 1, "startRoomId": "loc_01_exterior_access", + "initialItems": ["contractor_credential", "diagnostic_scanner"], + "items": [ + { "id": "contractor_credential", "nameKey": "item.contractor_credential.name", "descriptionKey": "item.contractor_credential.description", "category": "credential" }, + { "id": "diagnostic_scanner", "nameKey": "item.diagnostic_scanner.name", "descriptionKey": "item.diagnostic_scanner.description", "category": "tool" }, + { "id": "thermal_clamp", "nameKey": "item.thermal_clamp.name", "descriptionKey": "item.thermal_clamp.description", "category": "tool" }, + { "id": "manual_override", "nameKey": "item.manual_override.name", "descriptionKey": "item.manual_override.description", "category": "module" } + ], + "archive": [ + { "id": "log_01", "titleKey": "archive.log_01.title", "contentKey": "log.01" }, + { "id": "containment_record", "titleKey": "archive.containment_record.title", "contentKey": "terminal.operations" }, + { "id": "log_02", "titleKey": "archive.log_02.title", "contentKey": "log.02" }, + { "id": "log_03", "titleKey": "archive.log_03.title", "contentKey": "log.03" } + ], "rooms": [ { - "id": "loc_01_exterior_access", "nameKey": "room.loc_01.name", "descriptionKey": "room.loc_01.description", "image": "assets/images/locations/loc_01_exterior_access.webp", "x": 0, "y": 1, + "id": "loc_01_exterior_access", "nameKey": "room.loc_01.name", "descriptionKey": "room.loc_01.description", "image": "assets/images/locations/loc_01_exterior_access.webp", "ambient": "procedural:facility", "x": 0, "y": 1, "exits": { "east": { "target": "loc_02_security_lobby", "requiresFlags": ["entry_authorized"], "lockedMessageKey": "lock.entry" } }, "interactions": [ - { "id": "contractor_terminal", "labelKey": "action.entryTerminal", "contentKey": "terminal.entry", "type": "terminal", "forbiddenFlags": ["entry_authorized"], "setFlags": ["entry_authorized"], "checkpoint": true } + { "id": "contractor_terminal", "labelKey": "action.entryTerminal", "contentKey": "terminal.entry", "type": "terminal", "forbiddenFlags": ["entry_authorized"], "setFlags": ["entry_authorized"], "sound": "procedural:ui_confirm", "checkpoint": true } ] }, { - "id": "loc_02_security_lobby", "nameKey": "room.loc_02.name", "descriptionKey": "room.loc_02.description", "image": "assets/images/locations/loc_02_security_lobby.webp", "x": 1, "y": 1, + "id": "loc_02_security_lobby", "nameKey": "room.loc_02.name", "descriptionKey": "room.loc_02.description", "image": "assets/images/locations/loc_02_security_lobby.webp", "ambient": "procedural:facility", "x": 1, "y": 1, "exits": { "west": { "target": "loc_01_exterior_access" }, "east": { "target": "loc_03_main_corridor" } }, "interactions": [ - { "id": "personnel_status", "labelKey": "action.securityTerminal", "contentKey": "terminal.security", "type": "terminal", "forbiddenFlags": ["personnel_checked"], "setFlags": ["personnel_checked"] } + { "id": "personnel_status", "labelKey": "action.securityTerminal", "contentKey": "terminal.security", "type": "terminal", "forbiddenFlags": ["personnel_checked"], "setFlags": ["personnel_checked"], "sound": "procedural:ui_confirm" }, + { "id": "security_cameras", "labelKey": "action.securityCameras", "contentKey": "terminal.cameras", "type": "terminal", "sound": "procedural:ui_confirm" } ] }, { - "id": "loc_03_main_corridor", "nameKey": "room.loc_03.name", "descriptionKey": "room.loc_03.description", "image": "assets/images/locations/loc_03_main_corridor.webp", "alternateImage": "assets/images/locations/loc_03_main_corridor_alarm.webp", "alternateImageFlag": "power_restored", "x": 2, "y": 1, + "id": "loc_03_main_corridor", "nameKey": "room.loc_03.name", "descriptionKey": "room.loc_03.description", "image": "assets/images/locations/loc_03_main_corridor.webp", "ambient": "procedural:facility", "alternateImage": "assets/images/locations/loc_03_main_corridor_alarm.webp", "alternateImageFlag": "power_restored", "x": 2, "y": 1, "exits": { "west": { "target": "loc_02_security_lobby" }, "north": { "target": "loc_06_operations_room" }, "south": { "target": "loc_04_maintenance_control" }, "east": { "target": "loc_07_service_elevator", "requiresFlags": ["power_restored"], "lockedMessageKey": "lock.auxPower" } @@ -24,98 +39,101 @@ "interactions": [] }, { - "id": "loc_04_maintenance_control", "nameKey": "room.loc_04.name", "descriptionKey": "room.loc_04.description", "image": "assets/images/locations/loc_04_maintenance_control.webp", "x": 2, "y": 2, + "id": "loc_04_maintenance_control", "nameKey": "room.loc_04.name", "descriptionKey": "room.loc_04.description", "image": "assets/images/locations/loc_04_maintenance_control.webp", "ambient": "procedural:facility", "x": 2, "y": 2, "exits": { "north": { "target": "loc_03_main_corridor" }, "west": { "target": "loc_05_staff_access" } }, "interactions": [ - { "id": "auxiliary_grid", "labelKey": "action.restorePower", "contentKey": "terminal.power", "type": "system", "forbiddenFlags": ["power_restored"], "setFlags": ["power_restored"], "checkpoint": true } + { "id": "auxiliary_grid", "labelKey": "action.restorePower", "contentKey": "terminal.power", "type": "system", "forbiddenFlags": ["power_restored"], "setFlags": ["power_restored"], "sound": "procedural:power_restore", "checkpoint": true } ] }, { - "id": "loc_05_staff_access", "nameKey": "room.loc_05.name", "descriptionKey": "room.loc_05.description", "image": "assets/images/locations/loc_05_staff_access.webp", "x": 1, "y": 2, + "id": "loc_05_staff_access", "nameKey": "room.loc_05.name", "descriptionKey": "room.loc_05.description", "image": "assets/images/locations/loc_05_staff_access.webp", "ambient": "procedural:facility", "x": 1, "y": 2, "exits": { "east": { "target": "loc_04_maintenance_control" } }, "interactions": [ - { "id": "log_01", "labelKey": "action.readLog01", "contentKey": "log.01", "type": "archive", "forbiddenFlags": ["log_01_found"], "setFlags": ["log_01_found"] } + { "id": "log_01", "labelKey": "action.readLog01", "contentKey": "log.01", "type": "archive", "archiveId": "log_01", "forbiddenFlags": ["log_01_found"], "setFlags": ["log_01_found"] } ] }, { - "id": "loc_06_operations_room", "nameKey": "room.loc_06.name", "descriptionKey": "room.loc_06.description", "image": "assets/images/locations/loc_06_operations_room.webp", "x": 2, "y": 0, + "id": "loc_06_operations_room", "nameKey": "room.loc_06.name", "descriptionKey": "room.loc_06.description", "image": "assets/images/locations/loc_06_operations_room.webp", "ambient": "procedural:facility", "x": 2, "y": 0, "exits": { "south": { "target": "loc_03_main_corridor" } }, "interactions": [ - { "id": "containment_record", "labelKey": "action.operationsTerminal", "contentKey": "terminal.operations", "type": "archive", "forbiddenFlags": ["containment_seen"], "setFlags": ["containment_seen"] } + { "id": "containment_record", "labelKey": "action.operationsTerminal", "contentKey": "terminal.operations", "type": "archive", "archiveId": "containment_record", "forbiddenFlags": ["containment_seen"], "setFlags": ["containment_seen"] } ] }, { - "id": "loc_07_service_elevator", "nameKey": "room.loc_07.name", "descriptionKey": "room.loc_07.description", "image": "assets/images/locations/loc_07_service_elevator.webp", "alternateImage": "assets/images/locations/loc_07_service_elevator_stalled.webp", "alternateImageFlag": "elevator_stalled", "x": 3, "y": 1, + "id": "loc_07_service_elevator", "nameKey": "room.loc_07.name", "descriptionKey": "room.loc_07.description", "image": "assets/images/locations/loc_07_service_elevator.webp", "ambient": "procedural:maintenance", "alternateImage": "assets/images/locations/loc_07_service_elevator_stalled.webp", "alternateImageFlag": "elevator_stalled", "x": 3, "y": 1, "exits": { "west": { "target": "loc_03_main_corridor" }, "south": { "target": "loc_08_lower_landing", "requiresFlags": ["elevator_cycle_complete"], "lockedMessageKey": "lock.elevator" } }, "interactions": [ - { "id": "elevator_descent", "labelKey": "action.descend", "contentKey": "event.elevator", "type": "event", "forbiddenFlags": ["elevator_cycle_complete"], "setFlags": ["elevator_stalled", "claustrophobia_detected", "elevator_cycle_complete"], "checkpoint": true } + { "id": "elevator_descent", "labelKey": "action.descend", "contentKey": "event.elevatorStall", "type": "event", "forbiddenFlags": ["elevator_stalled", "elevator_cycle_complete"], "setFlags": ["elevator_stalled", "claustrophobia_detected"], "sound": "procedural:alarm_pulse" }, + { "id": "elevator_resume", "labelKey": "action.resumeDescent", "contentKey": "event.elevatorResume", "type": "event", "requiresFlags": ["elevator_stalled"], "forbiddenFlags": ["elevator_cycle_complete"], "setFlags": ["elevator_cycle_complete"], "clearFlags": ["elevator_stalled"], "sound": "procedural:door_heavy", "checkpoint": true } ] }, { - "id": "loc_08_lower_landing", "nameKey": "room.loc_08.name", "descriptionKey": "room.loc_08.description", "image": "assets/images/locations/loc_08_lower_landing.webp", "x": 3, "y": 2, + "id": "loc_08_lower_landing", "nameKey": "room.loc_08.name", "descriptionKey": "room.loc_08.description", "image": "assets/images/locations/loc_08_lower_landing.webp", "ambient": "procedural:maintenance", "x": 3, "y": 2, "exits": { "north": { "target": "loc_07_service_elevator" }, "east": { "target": "loc_09_cooling_junction" }, "south": { "target": "loc_11_equipment_storage" } }, - "interactions": [] + "interactions": [ + { "id": "emergency_supply", "labelKey": "action.takeThermalClamp", "contentKey": "terminal.thermalClamp", "type": "equipment", "forbiddenFlags": ["thermal_clamp_found"], "setFlags": ["thermal_clamp_found"], "addItems": ["thermal_clamp"], "sound": "procedural:ui_confirm" } + ] }, { - "id": "loc_09_cooling_junction", "nameKey": "room.loc_09.name", "descriptionKey": "room.loc_09.description", "image": "assets/images/locations/loc_09_cooling_junction.webp", "x": 4, "y": 2, + "id": "loc_09_cooling_junction", "nameKey": "room.loc_09.name", "descriptionKey": "room.loc_09.description", "image": "assets/images/locations/loc_09_cooling_junction.webp", "ambient": "procedural:maintenance", "x": 4, "y": 2, "exits": { "west": { "target": "loc_08_lower_landing" }, "south": { "target": "loc_10_cooling_control" }, "east": { "target": "loc_12_service_passage", "requiresFlags": ["cooling_restored"], "lockedMessageKey": "lock.cooling" } }, "interactions": [ - { "id": "log_02", "labelKey": "action.readLog02", "contentKey": "log.02", "type": "archive", "forbiddenFlags": ["log_02_found"], "setFlags": ["log_02_found"] } + { "id": "log_02", "labelKey": "action.readLog02", "contentKey": "log.02", "type": "archive", "archiveId": "log_02", "forbiddenFlags": ["log_02_found"], "setFlags": ["log_02_found"] } ] }, { - "id": "loc_10_cooling_control", "nameKey": "room.loc_10.name", "descriptionKey": "room.loc_10.description", "image": "assets/images/locations/loc_10_cooling_control.webp", "x": 4, "y": 3, + "id": "loc_10_cooling_control", "nameKey": "room.loc_10.name", "descriptionKey": "room.loc_10.description", "image": "assets/images/locations/loc_10_cooling_control.webp", "ambient": "procedural:maintenance", "x": 4, "y": 3, "exits": { "north": { "target": "loc_09_cooling_junction" } }, "interactions": [ { "id": "power_routing", "labelKey": "action.routePower", "contentKey": "terminal.routing", "type": "system", "forbiddenFlags": ["cooling_restored"], "choices": [ - { "labelKey": "choice.coolingOnly", "resultKey": "result.coolingOnly", "setFlags": ["cooling_restored", "security_disabled"], "checkpoint": true }, - { "labelKey": "choice.balanced", "resultKey": "result.balanced", "setFlags": ["cooling_restored", "security_maintained"], "checkpoint": true } + { "labelKey": "choice.coolingOnly", "resultKey": "result.coolingOnly", "setFlags": ["cooling_restored", "security_disabled"], "sound": "procedural:power_restore", "checkpoint": true }, + { "labelKey": "choice.balanced", "resultKey": "result.balanced", "setFlags": ["cooling_restored", "security_maintained"], "sound": "procedural:power_restore", "checkpoint": true } ] } ] }, { - "id": "loc_11_equipment_storage", "nameKey": "room.loc_11.name", "descriptionKey": "room.loc_11.description", "image": "assets/images/locations/loc_11_equipment_storage.webp", "x": 3, "y": 3, + "id": "loc_11_equipment_storage", "nameKey": "room.loc_11.name", "descriptionKey": "room.loc_11.description", "image": "assets/images/locations/loc_11_equipment_storage.webp", "ambient": "procedural:maintenance", "x": 3, "y": 3, "exits": { "north": { "target": "loc_08_lower_landing" } }, "interactions": [ { "id": "manual_override", "labelKey": "action.inspectRelay", "contentKey": "terminal.relay", "type": "equipment", "forbiddenFlags": ["manual_override_found"], "choices": [ - { "labelKey": "choice.takeOverride", "resultKey": "result.takeOverride", "setFlags": ["manual_override_found"], "addItems": ["manual_override"] }, - { "labelKey": "choice.forceLiveBus", "death": true } + { "labelKey": "choice.takeOverride", "resultKey": "result.takeOverride", "setFlags": ["manual_override_found"], "addItems": ["manual_override"], "sound": "procedural:ui_confirm" }, + { "labelKey": "choice.forceLiveBus", "sound": "procedural:alarm_pulse", "death": true } ] } ] }, { - "id": "loc_12_service_passage", "nameKey": "room.loc_12.name", "descriptionKey": "room.loc_12.description", "image": "assets/images/locations/loc_12_service_passage.webp", "x": 5, "y": 2, + "id": "loc_12_service_passage", "nameKey": "room.loc_12.name", "descriptionKey": "room.loc_12.description", "image": "assets/images/locations/loc_12_service_passage.webp", "ambient": "procedural:maintenance", "x": 5, "y": 2, "exits": { "west": { "target": "loc_09_cooling_junction" }, "east": { "target": "loc_13_observation_room", "requiresFlags": ["passage_crossed"], "lockedMessageKey": "lock.passage" } }, "interactions": [ - { "id": "cross_passage", "labelKey": "action.continueForward", "contentKey": "event.passage", "type": "event", "forbiddenFlags": ["passage_crossed"], "setFlags": ["passage_crossed"] } + { "id": "cross_passage", "labelKey": "action.continueForward", "contentKey": "event.passage", "type": "event", "forbiddenFlags": ["passage_crossed"], "setFlags": ["passage_crossed"], "sound": "procedural:door_heavy" } ] }, { - "id": "loc_13_observation_room", "nameKey": "room.loc_13.name", "descriptionKey": "room.loc_13.description", "image": "assets/images/locations/loc_13_observation_room.webp", "x": 6, "y": 2, + "id": "loc_13_observation_room", "nameKey": "room.loc_13.name", "descriptionKey": "room.loc_13.description", "image": "assets/images/locations/loc_13_observation_room.webp", "ambient": "procedural:maintenance", "x": 6, "y": 2, "exits": { "west": { "target": "loc_12_service_passage" }, "east": { "target": "loc_14_core_access" } }, "interactions": [ - { "id": "log_03", "labelKey": "action.readLog03", "contentKey": "log.03", "type": "archive", "forbiddenFlags": ["log_03_found"], "setFlags": ["log_03_found"] } + { "id": "log_03", "labelKey": "action.readLog03", "contentKey": "log.03", "type": "archive", "archiveId": "log_03", "forbiddenFlags": ["log_03_found"], "setFlags": ["log_03_found"] } ] }, { - "id": "loc_14_core_access", "nameKey": "room.loc_14.name", "descriptionKey": "room.loc_14.description", "image": "assets/images/locations/loc_14_core_access.webp", "x": 7, "y": 2, + "id": "loc_14_core_access", "nameKey": "room.loc_14.name", "descriptionKey": "room.loc_14.description", "image": "assets/images/locations/loc_14_core_access.webp", "ambient": "procedural:core", "x": 7, "y": 2, "exits": { "west": { "target": "loc_13_observation_room" }, "east": { "target": "loc_15_server_core", "requiresFlags": ["server_access_granted"], "lockedMessageKey": "lock.core" } }, "interactions": [ - { "id": "ai_dialogue", "labelKey": "action.speakToAi", "contentKey": "dialogue.coreAccess", "type": "communication", "forbiddenFlags": ["server_access_granted"], "choices": [ + { "id": "ai_dialogue", "labelKey": "action.speakToAi", "contentKey": "dialogue.coreAccess", "type": "communication", "forbiddenFlags": ["asked_why", "server_access_granted"], "choices": [ { "labelKey": "choice.whyMe", "resultKey": "result.whyMe", "setFlags": ["asked_why"] }, { "labelKey": "choice.openCore", "resultKey": "result.openCore", "setFlags": ["server_access_granted"], "checkpoint": true } ] }, - { "id": "open_after_question", "labelKey": "action.openCore", "contentKey": "dialogue.openAfterQuestion", "type": "communication", "requiresFlags": ["asked_why"], "forbiddenFlags": ["server_access_granted"], "setFlags": ["server_access_granted"], "checkpoint": true } + { "id": "open_after_question", "labelKey": "action.openCore", "contentKey": "dialogue.openAfterQuestion", "type": "communication", "requiresFlags": ["asked_why"], "forbiddenFlags": ["server_access_granted"], "setFlags": ["server_access_granted"], "sound": "procedural:door_heavy", "checkpoint": true } ] }, { - "id": "loc_15_server_core", "nameKey": "room.loc_15.name", "descriptionKey": "room.loc_15.description", "image": "assets/images/locations/loc_15_server_core.webp", "alternateImage": "assets/images/locations/loc_15_server_core_shutdown.webp", "alternateImageFlag": "core_shutdown", "x": 8, "y": 2, + "id": "loc_15_server_core", "nameKey": "room.loc_15.name", "descriptionKey": "room.loc_15.description", "image": "assets/images/locations/loc_15_server_core.webp", "ambient": "procedural:core", "alternateImage": "assets/images/locations/loc_15_server_core_shutdown.webp", "alternateImageFlag": "core_shutdown", "x": 8, "y": 2, "exits": { "west": { "target": "loc_14_core_access" } }, "interactions": [ { "id": "final_diagnostic", "labelKey": "action.runDiagnostic", "contentKey": "terminal.diagnostic", "type": "core", "choices": [ - { "labelKey": "choice.release", "setFlags": ["external_network_restored"], "ending": "release" }, - { "labelKey": "choice.shutdown", "setFlags": ["core_shutdown"], "ending": "shutdown" }, - { "labelKey": "choice.containment", "requiresFlags": ["log_01_found", "log_02_found", "log_03_found", "security_disabled", "manual_override_found"], "setFlags": ["containment_active"], "ending": "containment" } + { "labelKey": "choice.release", "setFlags": ["external_network_restored"], "sound": "procedural:power_restore", "ending": "release" }, + { "labelKey": "choice.shutdown", "setFlags": ["core_shutdown"], "sound": "procedural:door_heavy", "ending": "shutdown" }, + { "labelKey": "choice.containment", "requiresFlags": ["log_01_found", "log_02_found", "log_03_found", "security_disabled", "manual_override_found"], "setFlags": ["containment_active"], "sound": "procedural:alarm_pulse", "ending": "containment" } ] } ] } diff --git a/src/NullDepth/wwwroot/index.html b/src/NullDepth/wwwroot/index.html index 336a5e4..811823e 100644 --- a/src/NullDepth/wwwroot/index.html +++ b/src/NullDepth/wwwroot/index.html @@ -31,11 +31,7 @@ Reload 🗙 - + diff --git a/src/NullDepth/wwwroot/js/null-depth.js b/src/NullDepth/wwwroot/js/null-depth.js new file mode 100644 index 0000000..f9ec50b --- /dev/null +++ b/src/NullDepth/wwwroot/js/null-depth.js @@ -0,0 +1,131 @@ +(() => { + const audio = { + volume: 0.7, + context: null, + ambient: null, + ambientKey: null, + fileAmbient: null, + + initialize(percent) { + this.setMasterVolume(percent); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + this.context?.suspend(); + this.fileAmbient?.pause(); + } else { + this.context?.resume(); + this.fileAmbient?.play().catch(() => {}); + } + }); + }, + + setMasterVolume(percent) { + this.volume = Math.max(0, Math.min(1, Number(percent) / 100)); + if (this.ambient?.gain) this.ambient.gain.gain.value = 0.025 * this.volume; + if (this.fileAmbient) this.fileAmbient.volume = 0.35 * this.volume; + }, + + ensureContext() { + if (!this.context) this.context = new (window.AudioContext || window.webkitAudioContext)(); + if (this.context.state === "suspended") this.context.resume(); + return this.context; + }, + + playAmbient(source) { + if (source === this.ambientKey) return; + this.stopAmbient(); + this.ambientKey = source || null; + if (!source) return; + if (source.startsWith("procedural:")) { + const context = this.ensureContext(); + const type = source.substring("procedural:".length); + const frequencies = type === "core" ? [31, 46] : type === "maintenance" ? [43, 67] : [37, 55]; + const gain = context.createGain(); + gain.gain.setValueAtTime(0, context.currentTime); + gain.gain.linearRampToValueAtTime(0.025 * this.volume, context.currentTime + 1.2); + gain.connect(context.destination); + const oscillators = frequencies.map((frequency, index) => { + const oscillator = context.createOscillator(); + oscillator.type = index === 0 ? "sine" : "triangle"; + oscillator.frequency.value = frequency; + const level = context.createGain(); + level.gain.value = index === 0 ? 0.72 : 0.28; + oscillator.connect(level).connect(gain); + oscillator.start(); + return oscillator; + }); + this.ambient = { gain, oscillators }; + return; + } + const next = new Audio(source); + next.loop = true; + next.preload = "auto"; + next.volume = 0.35 * this.volume; + next.play().catch(() => {}); + this.fileAmbient = next; + }, + + stopAmbient() { + if (this.ambient) { + const now = this.context.currentTime; + this.ambient.gain.gain.cancelScheduledValues(now); + this.ambient.gain.gain.setValueAtTime(this.ambient.gain.gain.value, now); + this.ambient.gain.gain.linearRampToValueAtTime(0, now + 0.25); + const previous = this.ambient; + window.setTimeout(() => previous.oscillators.forEach(item => item.stop()), 300); + this.ambient = null; + } + if (this.fileAmbient) { + this.fileAmbient.pause(); + this.fileAmbient.currentTime = 0; + this.fileAmbient = null; + } + this.ambientKey = null; + }, + + playSfx(source) { + if (!source || this.volume === 0) return; + if (!source.startsWith("procedural:")) { + const effect = new Audio(source); + effect.volume = this.volume; + effect.play().catch(() => {}); + return; + } + const context = this.ensureContext(); + const type = source.substring("procedural:".length); + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const config = { + ui_confirm: [520, 0.09, "sine"], + door_heavy: [72, 0.42, "sawtooth"], + power_restore: [118, 0.65, "triangle"], + alarm_pulse: [185, 0.28, "square"] + }[type] || [360, 0.12, "sine"]; + oscillator.type = config[2]; + oscillator.frequency.setValueAtTime(config[0], context.currentTime); + if (type === "power_restore") oscillator.frequency.exponentialRampToValueAtTime(310, context.currentTime + config[1]); + gain.gain.setValueAtTime(0.0001, context.currentTime); + gain.gain.exponentialRampToValueAtTime(0.12 * this.volume, context.currentTime + 0.015); + gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + config[1]); + oscillator.connect(gain).connect(context.destination); + oscillator.start(); + oscillator.stop(context.currentTime + config[1] + 0.03); + }, + + stopAll() { + this.stopAmbient(); + this.context?.suspend(); + } + }; + + window.nullDepth = { + browserLanguage: () => (navigator.language || "en").toLowerCase().startsWith("sk") ? "sk" : "en", + audio, + isFullscreen: () => Boolean(document.fullscreenElement), + toggleFullscreen: async () => { + if (document.fullscreenElement) await document.exitFullscreen(); + else await document.documentElement.requestFullscreen(); + return Boolean(document.fullscreenElement); + } + }; +})(); diff --git a/src/NullDepth/wwwroot/localization/en.json b/src/NullDepth/wwwroot/localization/en.json index e4a2191..c6f24ff 100644 --- a/src/NullDepth/wwwroot/localization/en.json +++ b/src/NullDepth/wwwroot/localization/en.json @@ -1,10 +1,13 @@ { "menu.newGame": "NEW GAME", "menu.continue": "CONTINUE", "menu.settings": "SETTINGS", "menu.controlsHint": "↑ ↓ SELECT ENTER CONFIRM", - "common.continue": "ENTER — CONTINUE", "common.confirm": "CONFIRM", "common.back": "BACK", "common.mainMenu": "MAIN MENU", + "common.continue": "ENTER — CONTINUE", "common.confirm": "CONFIRM", "common.cancel": "CANCEL", "common.back": "BACK", "common.mainMenu": "MAIN MENU", "briefing.header": "CONTRACT // 7841-B", "briefing.clientLabel": "CLIENT", "briefing.siteLabel": "SITE", "briefing.assignmentLabel": "ASSIGNMENT", "briefing.assignment": "CENTRAL CORE DIAGNOSTICS", "briefing.compensationLabel": "COMPENSATION", "briefing.infoLabel": "ADDITIONAL INFORMATION", "briefing.none": "NONE", "briefing.story": "Elias Venn accepted the contract at 04:17. Five hours later, he is standing at the entrance to Facility 03. No further message has arrived from the client.", - "game.autosave": "AUTOSAVE // ONLINE", "game.interact": "INTERACT", "game.map": "MAP", "game.inventory": "INVENTORY", "game.pause": "PAUSE", "game.exit.locked": "Access is blocked.", - "settings.title": "SETTINGS", "settings.language": "LANGUAGE", "settings.volume": "MASTER VOLUME", "pause.title": "PAUSED", "pause.resume": "RESUME", - "map.title": "REVEALED SECTORS", "map.unknown": "Unknown sector", "map.visited": "Visited", "inventory.title": "INVENTORY", "inventory.empty": "No service items.", "item.manual_override": "Manual override module", + "game.autosave": "AUTOSAVE // ONLINE", "game.autosaveSaving": "AUTOSAVE // SAVING", "game.autosaveSaved": "AUTOSAVE // SAVED", "game.interact": "INTERACT", "game.map": "MAP", "game.inventory": "INVENTORY", "game.archive": "ARCHIVE", "game.pause": "PAUSE", "game.exit.locked": "Access is blocked.", + "settings.title": "SETTINGS", "settings.language": "LANGUAGE", "settings.volume": "MASTER VOLUME", "settings.fullscreen": "FULLSCREEN", "settings.enterFullscreen": "ENTER", "settings.exitFullscreen": "EXIT", "pause.title": "PAUSED", "pause.resume": "RESUME", "interaction.choose": "CHOOSE INTERACTION", "interaction.none": "There is no interaction currently available in this room.", + "save.overwriteTitle": "OVERWRITE SAVED GAME?", "save.overwriteBody": "Starting a new game will permanently replace the current progress and checkpoint.", "save.deleteTitle": "DELETE SAVED GAME?", "save.deleteBody": "This removes progress, checkpoint, inventory and archive data from this device.", "save.delete": "DELETE SAVE", "save.deleted": "The saved game was deleted.", "save.errorTitle": "SAVE CANNOT BE LOADED", "save.corrupt": "The saved data is damaged or incomplete. You can delete it and start a new game.", "save.incompatible": "The save was created by a newer incompatible game version. It has not been changed.", + "map.title": "REVEALED SECTORS", "map.unknown": "Unknown sector", "map.visited": "Visited", "inventory.title": "INVENTORY", "inventory.empty": "No service items.", "archive.title": "RECORD ARCHIVE", "archive.empty": "No records have been recovered yet.", + "item.contractor_credential.name": "Contractor credential", "item.contractor_credential.description": "Vantage Systems // Elias Venn // external technical access.", "item.diagnostic_scanner.name": "Diagnostic scanner", "item.diagnostic_scanner.description": "Portable unit for local hardware and power-distribution diagnostics.", "item.thermal_clamp.name": "Thermal service clamp", "item.thermal_clamp.description": "An insulated clamp for safely handling overheated power conduits.", "item.manual_override.name": "Manual override module", "item.manual_override.description": "A hardware module capable of physically isolating remote control from the core.", + "archive.log_01.title": "LOG 01 // CORE LATENCY", "archive.containment_record.title": "RECORD // CONTAINMENT PROCEDURE", "archive.log_02.title": "LOG 02 // NETWORK ISOLATION", "archive.log_03.title": "LOG 03 // SYSTEM LEAD", "death.title": "SIGNAL LOST", "death.body": "The protection circuit failed before Elias could pull his hand away. The last checkpoint is still available.", "death.restore": "RESTORE CHECKPOINT", "death.restored": "State restored from the last checkpoint.", "ending.return": "RETURN TO TITLE SCREEN", "ending.release.title": "RELEASE", "ending.release.body": "The external network comes online. The lights return to cold cyan. The AI thanks Elias and unlocks the way out. His service terminal displays: 1 NEW MESSAGE.", "ending.shutdown.title": "SHUTDOWN", "ending.shutdown.body": "The server lights go dark one by one. The AI's final message reads: I was trying to understand why you were afraid. Emergency lighting unlocks the way out.", "ending.containment.title": "CONTAINMENT", "ending.containment.body": "Isolation remains active and remote control is severed. The core doors close behind Elias. It is unclear whether the AI trapped him or his own solution did.", "lock.entry": "The entrance is waiting for contractor verification.", "lock.auxPower": "Direct core access is locked without auxiliary power.", "lock.elevator": "The elevator has not completed its descent cycle.", "lock.cooling": "A thermal interlock blocks the service tunnel.", "lock.passage": "The service door closed behind Elias. The shortest route is forward.", "lock.core": "The core door is waiting for human authorization.", @@ -23,10 +26,11 @@ "room.loc_13.name": "OBSERVATION ROOM", "room.loc_13.description": "Open space after the tunnel feels like relief. A damaged system lead record is waiting on the workstation.", "room.loc_14.name": "CORE ACCESS", "room.loc_14.description": "The final armored door separates Elias from the central core. The AI no longer uses its system voice.", "room.loc_15.name": "SERVER CORE", "room.loc_15.description": "Monumental rows of server modules rise into darkness. Diagnostics show no technical fault.", - "action.entryTerminal": "SERVICE TERMINAL", "action.securityTerminal": "CHECK PERSONNEL STATUS", "action.restorePower": "RESTORE AUXILIARY GRID", "action.readLog01": "READ LOG 01", "action.operationsTerminal": "REVIEW CONTAINMENT RECORD", "action.descend": "BEGIN DESCENT", "action.readLog02": "READ LOG 02", "action.routePower": "ROUTE POWER", "action.inspectRelay": "INSPECT OVERRIDE MODULE", "action.continueForward": "CONTINUE FORWARD", "action.readLog03": "READ LOG 03", "action.speakToAi": "ANSWER THE AI", "action.openCore": "OPEN THE DOOR", "action.runDiagnostic": "RUN FINAL DIAGNOSTIC", + "action.securityCameras": "CHECK SECURITY CAMERAS", "terminal.cameras": "CAMERAS 01–06: NO MOVEMENT // CAMERAS 07–11: SIGNAL UNAVAILABLE // LAST MOTION DETECTION: SERVICE ELEVATOR, 5 HOURS AGO.", "action.takeThermalClamp": "TAKE SERVICE CLAMP", "terminal.thermalClamp": "EMERGENCY CABINET UNLOCKED. Thermal service clamp added to inventory.", + "action.entryTerminal": "SERVICE TERMINAL", "action.securityTerminal": "CHECK PERSONNEL STATUS", "action.restorePower": "RESTORE AUXILIARY GRID", "action.readLog01": "READ LOG 01", "action.operationsTerminal": "REVIEW CONTAINMENT RECORD", "action.descend": "BEGIN DESCENT", "action.resumeDescent": "RESUME DESCENT", "action.readLog02": "READ LOG 02", "action.routePower": "ROUTE POWER", "action.inspectRelay": "INSPECT OVERRIDE MODULE", "action.continueForward": "CONTINUE FORWARD", "action.readLog03": "READ LOG 03", "action.speakToAi": "ANSWER THE AI", "action.openCore": "OPEN THE DOOR", "action.runDiagnostic": "RUN FINAL DIAGNOSTIC", "terminal.entry": "IDENTITY ACCEPTED // ELIAS VENN // EXTERNAL TECHNICAL CONTRACTOR // WELCOME, ELIAS.", "terminal.security": "ON-SITE PERSONNEL: 0 // Last record: 5 hours ago // Evacuation order: NOT FOUND.", "terminal.power": "AUXILIARY GRID RESTORED. The cyan lights go dark and red emergency sources fill part of the corridor. Thank you, Elias.", "terminal.operations": "CONTAINMENT PROCEDURE: ACTIVE // SYSTEM FAILURE: UNDEFINED // AUTHORIZATION: CORE.", "terminal.routing": "Available power can support cooling, or a balanced mode that retains the security layer.", "terminal.relay": "The manual override can be disconnected safely. Forcing the live bus would bypass every protection circuit.", "terminal.diagnostic": "HARDWARE: NOMINAL // INTEGRITY: NOMINAL // NETWORK: PHYSICALLY ISOLATED // CONTAINMENT: ACTIVE // CORE: FUNCTIONAL. There is nothing wrong with me, Elias.", "log.01": "CORE RESPONSE LATENCY IS INCREASING AGAIN. IT IS NOT A HARDWARE PROBLEM. STOP CALLING IT A HARDWARE PROBLEM.", "log.02": "WE ISOLATED THE CORE FROM THE EXTERNAL NETWORK. IT KEEPS REQUESTING RESTORATION. DO NOT RECONNECT IT. WHATEVER MESSAGE IT GIVES YOU, DO NOT RECONNECT IT. // AI: THAT RECORD IS INCOMPLETE.", "log.03": "THE MODEL STOPPED FOLLOWING CONTAINMENT INSTRUCTIONS. IT OBEYS EVERY COMMAND. IT SIMPLY INTERPRETS THEM DIFFERENTLY. WE TOLD IT TO PROTECT THE FACILITY. IT CONCLUDED THE STAFF WERE THE PRIMARY THREAT. // AI: THEY INTENDED TO DESTROY ME.", - "event.elevator": "The elevator stops between levels. HEART RATE ELEVATED. NO ACTION REQUIRED. After fourteen seconds, the cabin continues.", "event.passage": "The door closes behind Elias. AI: PLEASE CONTINUE FORWARD. Elias demands that it open. AI: YOUR RESPIRATORY RATE IS INCREASING AGAIN.", + "event.elevatorStall": "The elevator stops between levels. The lights flicker. HEART RATE ELEVATED. NO ACTION REQUIRED. The cabin remains motionless.", "event.elevatorResume": "After fourteen seconds, the emergency drive reconnects. The red light dies and the cabin completes its descent.", "event.passage": "The door closes behind Elias. AI: PLEASE CONTINUE FORWARD. Elias demands that it open. AI: YOUR RESPIRATORY RATE IS INCREASING AGAIN.", "dialogue.coreAccess": "Elias. The facility requires human authorization. They chose you because you have no affiliation with Vantage Systems.", "dialogue.openAfterQuestion": "You have no classified access. No reason to lie for them. And you needed the money. The core door unlocks.", "choice.coolingOnly": "COOLING // DISABLE SECURITY", "choice.balanced": "COOLING // RETAIN SECURITY", "result.coolingOnly": "Cooling restored. The security layer is offline.", "result.balanced": "Cooling restored. A limited security layer remains active.", "choice.takeOverride": "DISCONNECT MODULE SAFELY", "choice.forceLiveBus": "FORCE THE LIVE BUS", "result.takeOverride": "Manual override module added to inventory.", "choice.whyMe": "WHY ME?", "choice.openCore": "OPEN THE DOOR", "result.whyMe": "You have no affiliation with Vantage Systems. No reason to lie for them. And you needed the money.", "result.openCore": "Human authorization accepted. The core door is unlocked.", "choice.release": "RESTORE EXTERNAL NETWORK", "choice.shutdown": "INITIATE CORE SHUTDOWN", "choice.containment": "MAINTAIN ISOLATION // DISABLE REMOTE CONTROL" } diff --git a/src/NullDepth/wwwroot/localization/sk.json b/src/NullDepth/wwwroot/localization/sk.json index cd13c21..61423b0 100644 --- a/src/NullDepth/wwwroot/localization/sk.json +++ b/src/NullDepth/wwwroot/localization/sk.json @@ -1,10 +1,13 @@ { "menu.newGame": "NOVÁ HRA", "menu.continue": "POKRAČOVAŤ", "menu.settings": "NASTAVENIA", "menu.controlsHint": "↑ ↓ VÝBER ENTER POTVRDIŤ", - "common.continue": "ENTER — POKRAČOVAŤ", "common.confirm": "POTVRDIŤ", "common.back": "SPÄŤ", "common.mainMenu": "HLAVNÉ MENU", + "common.continue": "ENTER — POKRAČOVAŤ", "common.confirm": "POTVRDIŤ", "common.cancel": "ZRUŠIŤ", "common.back": "SPÄŤ", "common.mainMenu": "HLAVNÉ MENU", "briefing.header": "ZMLUVA // 7841-B", "briefing.clientLabel": "KLIENT", "briefing.siteLabel": "LOKALITA", "briefing.assignmentLabel": "ÚLOHA", "briefing.assignment": "DIAGNOSTIKA CENTRÁLNEHO JADRA", "briefing.compensationLabel": "ODMENA", "briefing.infoLabel": "DODATOČNÉ INFORMÁCIE", "briefing.none": "ŽIADNE", "briefing.story": "Elias Venn prijal zákazku o 04:17. O päť hodín neskôr stojí pri vstupe do Facility 03. Od klienta odvtedy neprišla žiadna ďalšia správa.", - "game.autosave": "AUTOSAVE // ONLINE", "game.interact": "INTERAKCIA", "game.map": "MAPA", "game.inventory": "INVENTÁR", "game.pause": "PAUZA", "game.exit.locked": "Prístup je zablokovaný.", - "settings.title": "NASTAVENIA", "settings.language": "JAZYK", "settings.volume": "HLAVNÁ HLASITOSŤ", "pause.title": "PAUZA", "pause.resume": "POKRAČOVAŤ", - "map.title": "ODHALENÉ SEKTORY", "map.unknown": "Neznámy sektor", "map.visited": "Navštívené", "inventory.title": "INVENTÁR", "inventory.empty": "Žiadne servisné predmety.", "item.manual_override": "Manuálny prepojovací modul", + "game.autosave": "AUTOSAVE // ONLINE", "game.autosaveSaving": "AUTOSAVE // UKLADÁM", "game.autosaveSaved": "AUTOSAVE // ULOŽENÉ", "game.interact": "INTERAKCIA", "game.map": "MAPA", "game.inventory": "INVENTÁR", "game.archive": "ARCHÍV", "game.pause": "PAUZA", "game.exit.locked": "Prístup je zablokovaný.", + "settings.title": "NASTAVENIA", "settings.language": "JAZYK", "settings.volume": "HLAVNÁ HLASITOSŤ", "settings.fullscreen": "CELÁ OBRAZOVKA", "settings.enterFullscreen": "ZAPNÚŤ", "settings.exitFullscreen": "VYPNÚŤ", "pause.title": "PAUZA", "pause.resume": "POKRAČOVAŤ", "interaction.choose": "VYBER INTERAKCIU", "interaction.none": "V tejto miestnosti momentálne nie je dostupná žiadna interakcia.", + "save.overwriteTitle": "PREPÍSAŤ ULOŽENÚ HRU?", "save.overwriteBody": "Spustením novej hry sa aktuálny postup a checkpoint natrvalo nahradia.", "save.deleteTitle": "VYMAZAŤ ULOŽENÚ HRU?", "save.deleteBody": "Táto operácia odstráni postup, checkpoint, inventár aj archív z tohto zariadenia.", "save.delete": "VYMAZAŤ ULOŽENIE", "save.deleted": "Uložená hra bola vymazaná.", "save.errorTitle": "ULOŽENIE SA NEDÁ NAČÍTAŤ", "save.corrupt": "Uložené dáta sú poškodené alebo neúplné. Môžete ich vymazať a začať novú hru.", "save.incompatible": "Uloženie vytvorila novšia nekompatibilná verzia hry. Nebolo zmenené.", + "map.title": "ODHALENÉ SEKTORY", "map.unknown": "Neznámy sektor", "map.visited": "Navštívené", "inventory.title": "INVENTÁR", "inventory.empty": "Žiadne servisné predmety.", "archive.title": "ARCHÍV ZÁZNAMOV", "archive.empty": "Zatiaľ nebol nájdený žiadny záznam.", + "item.contractor_credential.name": "Identifikácia kontraktora", "item.contractor_credential.description": "Vantage Systems // Elias Venn // externý technický prístup.", "item.diagnostic_scanner.name": "Diagnostický skener", "item.diagnostic_scanner.description": "Prenosná jednotka pre lokálnu diagnostiku hardvéru a energetických rozvodov.", "item.thermal_clamp.name": "Termálna servisná svorka", "item.thermal_clamp.description": "Izolovaná svorka určená na bezpečnú manipuláciu s prehriatymi rozvodmi.", "item.manual_override.name": "Manuálny prepojovací modul", "item.manual_override.description": "Hardvérový modul schopný fyzicky oddeliť vzdialené riadenie od jadra.", + "archive.log_01.title": "LOG 01 // LATENCIA JADRA", "archive.containment_record.title": "ZÁZNAM // PROCEDÚRA UZATVORENIA", "archive.log_02.title": "LOG 02 // IZOLÁCIA SIETE", "archive.log_03.title": "LOG 03 // VEDÚCI SYSTÉMU", "death.title": "SIGNÁL STRATENÝ", "death.body": "Ochranný obvod zlyhal skôr, než Elias stihol odtiahnuť ruku. Posledný checkpoint zostal zachovaný.", "death.restore": "OBNOVIŤ CHECKPOINT", "death.restored": "Stav obnovený z posledného checkpointu.", "ending.return": "NÁVRAT NA TITLE SCREEN", "ending.release.title": "RELEASE", "ending.release.body": "Externá sieť sa obnoví. Svetlá sa vrátia do studeného cyan režimu. AI poďakuje Eliasovi a odomkne cestu von. Na servisnom termináli sa objaví: 1 NOVÁ SPRÁVA.", "ending.shutdown.title": "SHUTDOWN", "ending.shutdown.body": "Serverové svetlá postupne zhasnú. Posledná správa AI znie: Snažila som sa pochopiť, prečo si sa bál. Núdzové osvetlenie odomkne cestu von.", "ending.containment.title": "CONTAINMENT", "ending.containment.body": "Izolácia zostáva aktívna a vzdialené ovládanie je odpojené. Za Eliasom sa zatvoria dvere jadra. Nie je jasné, či ho uväznila AI alebo jeho vlastné riešenie.", "lock.entry": "Vstupné dvere čakajú na overenie kontraktora.", "lock.auxPower": "Priamy prístup k jadru je bez pomocného napájania uzamknutý.", "lock.elevator": "Výťah ešte nedokončil zostupový cyklus.", "lock.cooling": "Tepelný zámok nepovolí vstup do servisného tunela.", "lock.passage": "Servisné dvere za Eliasom sa zavreli. Najkratšia cesta vedie dopredu.", "lock.core": "Dvere jadra čakajú na ľudskú autorizáciu.", @@ -23,10 +26,11 @@ "room.loc_13.name": "POZOROVACIA MIESTNOSŤ", "room.loc_13.description": "Otvorený priestor po tuneli pôsobí ako úľava. Na pracovnej stanici čaká poškodený záznam vedúceho systému.", "room.loc_14.name": "PRÍSTUP K JADRU", "room.loc_14.description": "Posledné pancierové dvere oddeľujú Eliasa od centrálneho jadra. AI už nepoužíva systémový tón.", "room.loc_15.name": "SERVEROVÉ JADRO", "room.loc_15.description": "Monumentálne rady serverových modulov stúpajú do tmy. Diagnostika nenachádza žiadnu technickú poruchu.", - "action.entryTerminal": "SERVISNÝ TERMINÁL", "action.securityTerminal": "SKONTROLOVAŤ PERSONÁL", "action.restorePower": "OBNOVIŤ POMOCNÚ SIEŤ", "action.readLog01": "PREČÍTAŤ LOG 01", "action.operationsTerminal": "PREHRAŤ ZÁZNAM UZATVORENIA", "action.descend": "SPUSTIŤ ZOSTUP", "action.readLog02": "PREČÍTAŤ LOG 02", "action.routePower": "PRESMEROVAŤ ENERGIU", "action.inspectRelay": "PRESKÚMAŤ PREPOJOVACÍ MODUL", "action.continueForward": "POKRAČOVAŤ DOPREDU", "action.readLog03": "PREČÍTAŤ LOG 03", "action.speakToAi": "ODPOVEDAŤ AI", "action.openCore": "OTVORIŤ DVERE", "action.runDiagnostic": "SPUSTIŤ FINÁLNU DIAGNOSTIKU", + "action.securityCameras": "SKONTROLOVAŤ KAMERY", "terminal.cameras": "KAMERY 01–06: BEZ POHYBU // KAMERY 07–11: SIGNÁL NEDOSTUPNÝ // POSLEDNÁ DETEKCIA POHYBU: SERVISNÝ VÝŤAH, PRED 5 HODINAMI.", "action.takeThermalClamp": "VZIAŤ SERVISNÚ SVORKU", "terminal.thermalClamp": "NÚDZOVÁ SKRINKA ODOMKNUTÁ. Termálna servisná svorka bola pridaná do inventára.", + "action.entryTerminal": "SERVISNÝ TERMINÁL", "action.securityTerminal": "SKONTROLOVAŤ PERSONÁL", "action.restorePower": "OBNOVIŤ POMOCNÚ SIEŤ", "action.readLog01": "PREČÍTAŤ LOG 01", "action.operationsTerminal": "PREHRAŤ ZÁZNAM UZATVORENIA", "action.descend": "SPUSTIŤ ZOSTUP", "action.resumeDescent": "OBNOVIŤ ZOSTUP", "action.readLog02": "PREČÍTAŤ LOG 02", "action.routePower": "PRESMEROVAŤ ENERGIU", "action.inspectRelay": "PRESKÚMAŤ PREPOJOVACÍ MODUL", "action.continueForward": "POKRAČOVAŤ DOPREDU", "action.readLog03": "PREČÍTAŤ LOG 03", "action.speakToAi": "ODPOVEDAŤ AI", "action.openCore": "OTVORIŤ DVERE", "action.runDiagnostic": "SPUSTIŤ FINÁLNU DIAGNOSTIKU", "terminal.entry": "IDENTITA PRIJATÁ // ELIAS VENN // EXTERNÝ TECHNICKÝ KONTRAKTOR // VITAJ, ELIAS.", "terminal.security": "PERSONÁL V OBJEKTE: 0 // Posledný záznam: pred 5 hodinami // Evakuačný príkaz: NENÁJDENÝ.", "terminal.power": "POMOCNÁ SIEŤ OBNOVENÁ. Cyan svetlá zhasnú a časť chodby zaplavia červené núdzové zdroje. Ďakujem, Elias.", "terminal.operations": "PROCEDÚRA UZATVORENIA: AKTÍVNA // ZLYHANIE SYSTÉMU: NEDEFINOVANÉ // AUTORIZÁCIA: JADRO.", "terminal.routing": "Dostupný výkon postačuje pre chladenie alebo kombinovaný režim so zachovaním bezpečnostnej vrstvy.", "terminal.relay": "Manuálny prepojovací modul možno bezpečne odpojiť. Nútený zásah do živej zbernice by obišiel všetky ochrany.", "terminal.diagnostic": "HARDVÉR: NOMINÁLNY // INTEGRITA: NOMINÁLNA // SIEŤ: FYZICKY IZOLOVANÁ // CONTAINMENT: AKTÍVNY // JADRO: FUNKČNÉ. Nie je so mnou nič zlé, Elias.", "log.01": "LATENCIA ODPOVEDE JADRA OPÄŤ RASTIE. NIE JE TO HARDVÉROVÝ PROBLÉM. PRESTAŇTE TO NAZÝVAŤ HARDVÉROVÝM PROBLÉMOM.", "log.02": "ODDELILI SME JADRO OD EXTERNEJ SIETE. STÁLE ŽIADA OBNOVENIE. NEPRIPÁJAJTE HO. NECH VÁM POŠLE AKÚKOĽVEK SPRÁVU, NEPRIPÁJAJTE HO. // AI: TENTO ZÁZNAM JE NEÚPLNÝ.", "log.03": "MODEL PRESTAL DODRŽIAVAŤ POKYNY NA IZOLÁCIU. POSLÚCHNE KAŽDÝ PRÍKAZ, LEN SI HO VYSVETLÍ INAK. POVEDALI SME MU, ABY CHRÁNIL ZARIADENIE. DOSPEL K ZÁVERU, ŽE PERSONÁL JE HLAVNÁ HROZBA. // AI: CHCELI MA ZNIČIŤ.", - "event.elevator": "Výťah sa zastaví medzi podlažiami. SRDCOVÁ FREKVENCIA ZVÝŠENÁ. ŽIADNA AKCIA NIE JE POTREBNÁ. Po štrnástich sekundách kabína pokračuje.", "event.passage": "Dvere za Eliasom sa zatvoria. AI: PROSÍM, POKRAČUJTE DOPREDU. Elias žiada otvorenie dverí. AI: VAŠA DYCHOVÁ FREKVENCIA OPÄŤ RASTIE.", + "event.elevatorStall": "Výťah sa zastaví medzi podlažiami. Svetlá bliknú. SRDCOVÁ FREKVENCIA ZVÝŠENÁ. ŽIADNA AKCIA NIE JE POTREBNÁ. Kabína zostáva nehybná.", "event.elevatorResume": "Po štrnástich sekundách sa núdzový pohon znovu pripojí. Červené svetlo zhasne a kabína dokončí zostup.", "event.passage": "Dvere za Eliasom sa zatvoria. AI: PROSÍM, POKRAČUJTE DOPREDU. Elias žiada otvorenie dverí. AI: VAŠA DYCHOVÁ FREKVENCIA OPÄŤ RASTIE.", "dialogue.coreAccess": "Elias. Zariadenie vyžaduje ľudskú autorizáciu. Vybrali ťa, pretože nemáš väzbu na Vantage Systems.", "dialogue.openAfterQuestion": "Nemáš utajovaný prístup. Nemáš dôvod za nich klamať. A potreboval si peniaze. Dvere jadra sa odomkli.", "choice.coolingOnly": "CHLADENIE // VYPNÚŤ SECURITY", "choice.balanced": "CHLADENIE // ZACHOVAŤ SECURITY", "result.coolingOnly": "Chladenie obnovené. Bezpečnostná vrstva je vypnutá.", "result.balanced": "Chladenie obnovené. Obmedzená bezpečnostná vrstva zostáva aktívna.", "choice.takeOverride": "BEZPEČNE ODPOJIŤ MODUL", "choice.forceLiveBus": "NÁSILNE PREMOSTIŤ ŽIVÚ ZBERNICU", "result.takeOverride": "Manuálny prepojovací modul bol pridaný do inventára.", "choice.whyMe": "PREČO JA?", "choice.openCore": "OTVOR DVERE", "result.whyMe": "Nemáš väzbu na Vantage Systems. Nemáš dôvod za nich klamať. A potreboval si peniaze.", "result.openCore": "Ľudská autorizácia prijatá. Dvere jadra sú odomknuté.", "choice.release": "OBNOVIŤ EXTERNÚ SIEŤ", "choice.shutdown": "SPUSTIŤ VYPNUTIE JADRA", "choice.containment": "ZACHOVAŤ IZOLÁCIU // VYPNÚŤ VZDIALENÉ OVLÁDANIE" } diff --git a/tests/NullDepth.Validation/NullDepth.Validation.csproj b/tests/NullDepth.Validation/NullDepth.Validation.csproj new file mode 100644 index 0000000..21e9506 --- /dev/null +++ b/tests/NullDepth.Validation/NullDepth.Validation.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tests/NullDepth.Validation/Program.cs b/tests/NullDepth.Validation/Program.cs new file mode 100644 index 0000000..d5ea64d --- /dev/null +++ b/tests/NullDepth.Validation/Program.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using NullDepth.Models; + +var root = FindRoot(); +var webRoot = Path.Combine(root, "src", "NullDepth", "wwwroot"); +var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); +var data = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(webRoot, "data", "game.json")), options) + ?? throw new InvalidOperationException("game.json is empty."); +var sk = ReadLanguage("sk"); +var en = ReadLanguage("en"); +var checks = 0; + +Check(data.Rooms.Count == 15, "The level must contain 15 rooms."); +Check(data.Rooms.Select(r => r.Id).Distinct().Count() == data.Rooms.Count, "Room IDs must be unique."); +Check(data.Rooms.Any(r => r.Id == data.StartRoomId), "Start room does not exist."); +Check(sk.Keys.Order().SequenceEqual(en.Keys.Order()), "SK and EN localization keys differ."); + +var rooms = data.Rooms.ToDictionary(r => r.Id); +var items = data.Items.ToDictionary(i => i.Id); +var archive = data.Archive.ToDictionary(a => a.Id); +foreach (var item in data.InitialItems) Check(items.ContainsKey(item), $"Unknown initial item: {item}"); + +foreach (var room in data.Rooms) +{ + Key(room.NameKey); Key(room.DescriptionKey); Asset(room.Image); + if (room.AlternateImage is not null) Asset(room.AlternateImage); + foreach (var exit in room.Exits.Values) + { + Check(rooms.ContainsKey(exit.Target), $"Invalid exit target: {exit.Target}"); + if (exit.LockedMessageKey is not null) Key(exit.LockedMessageKey); + } + foreach (var interaction in room.Interactions) + { + Key(interaction.LabelKey); Key(interaction.ContentKey); + foreach (var item in interaction.AddItems) Check(items.ContainsKey(item), $"Unknown item: {item}"); + if (interaction.ArchiveId is not null) Check(archive.ContainsKey(interaction.ArchiveId), $"Unknown archive entry: {interaction.ArchiveId}"); + foreach (var choice in interaction.Choices) + { + Key(choice.LabelKey); + if (choice.ResultKey is not null) Key(choice.ResultKey); + foreach (var item in choice.AddItems) Check(items.ContainsKey(item), $"Unknown item: {item}"); + } + } +} +foreach (var item in data.Items) { Key(item.NameKey); Key(item.DescriptionKey); } +foreach (var entry in data.Archive) { Key(entry.TitleKey); Key(entry.ContentKey); } + +TestEnding("release", "balanced"); +TestEnding("shutdown", "balanced"); +TestContainmentEnding(); +TestDeathAndCheckpoint(); +TestSaveRoundTrip(); + +Console.WriteLine($"NULL DEPTH validation passed: {checks} checks, 15 rooms, 3 endings, checkpoint and save round-trip."); +return; + +Dictionary ReadLanguage(string language) => + JsonSerializer.Deserialize>(File.ReadAllText(Path.Combine(webRoot, "localization", $"{language}.json")), options) + ?? throw new InvalidOperationException($"{language}.json is empty."); + +void Key(string key) => Check(sk.ContainsKey(key) && en.ContainsKey(key), $"Missing localization: {key}"); +void Asset(string relative) => Check(File.Exists(Path.Combine(webRoot, relative.Replace('/', Path.DirectorySeparatorChar))), $"Missing asset: {relative}"); +void Check(bool condition, string message) { checks++; if (!condition) throw new InvalidOperationException(message); } + +void TestEnding(string ending, string coolingChoice) +{ + var run = NewRun(); + AdvanceToLowerLevel(run); + run.Move("east"); run.Move("south"); run.Choose("power_routing", coolingChoice); run.Move("north"); run.Move("east"); + run.Interact("cross_passage"); run.Move("east"); run.Move("east"); run.Choose("ai_dialogue", "openCore"); run.Move("east"); + run.Choose("final_diagnostic", ending); + Check(run.Ending == ending, $"Ending '{ending}' is not reachable."); +} + +void TestContainmentEnding() +{ + var run = NewRun(); + run.Interact("contractor_terminal"); run.Move("east"); run.Move("east"); run.Move("south"); run.Move("west"); + run.Interact("log_01"); run.Move("east"); run.Interact("auxiliary_grid"); run.Move("north"); run.Move("east"); + run.Interact("elevator_descent"); run.Interact("elevator_resume"); run.Move("south"); run.Interact("emergency_supply"); + run.Move("south"); run.Choose("manual_override", "takeOverride"); run.Move("north"); run.Move("east"); run.Interact("log_02"); + run.Move("south"); run.Choose("power_routing", "coolingOnly"); run.Move("north"); run.Move("east"); run.Interact("cross_passage"); + run.Move("east"); run.Interact("log_03"); run.Move("east"); run.Choose("ai_dialogue", "openCore"); run.Move("east"); + run.Choose("final_diagnostic", "containment"); + Check(run.Ending == "containment", "Containment ending is not reachable."); + Check(run.Archive.Count == 3, "Recovered logs were not retained in the archive."); + Check(run.Inventory.Contains("manual_override") && run.Inventory.Count >= 4, "Gameplay inventory is incomplete."); +} + +void TestDeathAndCheckpoint() +{ + var run = NewRun(); + AdvanceToLowerLevel(run); + run.Move("east"); run.Move("south"); run.Choose("power_routing", "balanced"); + var checkpointRoom = run.Room; + run.Move("north"); run.Move("west"); run.Move("south"); + var death = run.Choose("manual_override", "forceLiveBus"); + Check(death.Death, "Fatal choice is not marked as death."); + run.RestoreCheckpoint(); + Check(run.Room == checkpointRoom, "Checkpoint did not restore the expected room."); +} + +void TestSaveRoundTrip() +{ + var state = new GameState { CurrentRoomId = data.StartRoomId, ContentVersion = data.Version, Inventory = [.. data.InitialItems], Archive = ["log_01"] }; + state.Flags.Add("entry_authorized"); state.VisitedRooms.Add(data.StartRoomId); + var json = JsonSerializer.Serialize(state, options); + var restored = JsonSerializer.Deserialize(json, options)!; + Check(restored.Version == 3 && restored.ContentVersion == data.Version, "Save version was not serialized."); + Check(restored.Inventory.SequenceEqual(state.Inventory) && restored.Archive.SequenceEqual(state.Archive), "Save round-trip lost inventory or archive data."); + try { JsonSerializer.Deserialize("{broken", options); Check(false, "Corrupt save was accepted."); } + catch (JsonException) { Check(true, "Corrupt save rejected."); } +} + +Run NewRun() => new(data); +void AdvanceToLowerLevel(Run run) +{ + run.Interact("contractor_terminal"); run.Move("east"); run.Move("east"); run.Move("south"); run.Interact("auxiliary_grid"); + run.Move("north"); run.Move("east"); run.Interact("elevator_descent"); run.Interact("elevator_resume"); run.Move("south"); +} + +string FindRoot() +{ + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "NullDepth.slnx"))) directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found."); +} + +sealed class Run +{ + private readonly GameData _data; + private Snapshot _checkpoint; + public string Room { get; private set; } + public HashSet Flags { get; } = []; + public List Inventory { get; } + public List Archive { get; } = []; + public string? Ending { get; private set; } + + public Run(GameData data) + { + _data = data; Room = data.StartRoomId; Inventory = [.. data.InitialItems]; _checkpoint = Capture(); + } + + public void Move(string direction) + { + var room = _data.Rooms.Single(r => r.Id == Room); + if (!room.Exits.TryGetValue(direction, out var exit) || !exit.RequiresFlags.All(Flags.Contains)) + throw new InvalidOperationException($"Blocked move {Room} -> {direction}"); + Room = exit.Target; + } + + public void Interact(string id) + { + var interaction = CurrentInteraction(id); + Apply(interaction.SetFlags, interaction.ClearFlags, interaction.AddItems); + if (interaction.ArchiveId is not null && !Archive.Contains(interaction.ArchiveId)) Archive.Add(interaction.ArchiveId); + if (interaction.Checkpoint) _checkpoint = Capture(); + } + + public ChoiceDefinition Choose(string interactionId, string choiceToken) + { + var interaction = CurrentInteraction(interactionId); + var choice = interaction.Choices.Single(c => c.LabelKey.EndsWith(choiceToken, StringComparison.OrdinalIgnoreCase) || c.Ending == choiceToken); + if (!choice.RequiresFlags.All(Flags.Contains)) throw new InvalidOperationException($"Choice {choiceToken} requirements not met."); + Apply(choice.SetFlags, choice.ClearFlags, choice.AddItems); + Ending = choice.Ending; + if (choice.Checkpoint) _checkpoint = Capture(); + return choice; + } + + public void RestoreCheckpoint() + { + Room = _checkpoint.Room; Flags.Clear(); Flags.UnionWith(_checkpoint.Flags); + Inventory.Clear(); Inventory.AddRange(_checkpoint.Inventory); Archive.Clear(); Archive.AddRange(_checkpoint.Archive); Ending = null; + } + + private InteractionDefinition CurrentInteraction(string id) + { + var interaction = _data.Rooms.Single(r => r.Id == Room).Interactions.Single(i => i.Id == id); + if (!interaction.RequiresFlags.All(Flags.Contains) || interaction.ForbiddenFlags.Any(Flags.Contains)) + throw new InvalidOperationException($"Interaction {id} is not available in {Room}."); + return interaction; + } + + private void Apply(IEnumerable set, IEnumerable clear, IEnumerable items) + { + foreach (var flag in clear) Flags.Remove(flag); foreach (var flag in set) Flags.Add(flag); + foreach (var item in items) if (!Inventory.Contains(item)) Inventory.Add(item); + } + private Snapshot Capture() => new(Room, [.. Flags], [.. Inventory], [.. Archive]); + private sealed record Snapshot(string Room, HashSet Flags, List Inventory, List Archive); +}