5 Commits
44 changed files with 567 additions and 34 deletions
+5
View File
@@ -56,6 +56,11 @@ jump={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
] ]
} }
toggle_console={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics] [physics]
@@ -0,0 +1,25 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
using Game.Features.Console.Domain;
namespace Game.Features.Console.Application;
public sealed class CommandExecute(ICommandRegistry registry)
{
public CommandResult Execute(string input)
{
if (!CommandLineParser.TryParse(input, out string name, out IReadOnlyList<string> arguments))
{
return CommandResult.Fail("No command entered.");
}
if (!registry.TryGet(name, out ICommand command))
{
return CommandResult.Fail($"Unknown command: {name}");
}
CommandResult result = CommandArgValidator.Validate(command.Args, arguments);
return !result.Success ? result : command.Execute(new CommandArgValues(arguments));
}
}
@@ -0,0 +1 @@
uid://f1ed8fhpbwik
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class ClearCommand : ICommand
{
public string Name => "clear";
public string Description => "Clears the console output.";
public IReadOnlyList<CommandArgSpec> Args { get; } = new CommandArgsBuilder().Build();
public CommandResult Execute(CommandArgValues args) => CommandResult.Ok(clearOutput: true);
}
@@ -0,0 +1 @@
uid://cmmw8kn2fc3bb
@@ -0,0 +1,13 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class EchoCommand : ICommand
{
public string Name => "echo";
public string Description => "Prints the given text back to the console.";
public IReadOnlyList<CommandArgSpec> Args { get; } = new CommandArgsBuilder().AddStringArg("text").Build();
public CommandResult Execute(CommandArgValues args) => CommandResult.Ok(args.GetString(0));
}
@@ -0,0 +1 @@
uid://cy6dkjf4fxkvj
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Application.Commands;
public sealed class HelpCommand(ICommandRegistry registry) : ICommand
{
public string Name => "help";
public string Description => "Lists all available commands.";
public IReadOnlyList<CommandArgSpec> Args { get; } = new CommandArgsBuilder().Build();
private static string FormatUsage(ICommand command)
{
string args = string.Join(" ", command.Args.Select(spec => $"<{spec.Name}:{spec}>"));
return $"{command.Name} {args}";
}
public CommandResult Execute(CommandArgValues args)
{
IEnumerable<string> lines = registry.All
.OrderBy(command => command.Name, StringComparer.Ordinal)
.Select(command => command.Args.Count == 0
? $"{command.Name} - {command.Description}"
: $"{command.Name} - {command.Description}\n usage: {FormatUsage(command)}");
return CommandResult.Ok($"List of available commands:\n{string.Join("\n", lines)}");
}
}
@@ -0,0 +1 @@
uid://bo7o0t08bb5u5
@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public sealed record CommandArgSpec(string Name, CommandArgType Type, IReadOnlyList<string> Choices = null)
{
public override string ToString() =>
Type == CommandArgType.Choice && Choices is not null
? string.Join("|", Choices)
: Type.ToString().ToLowerInvariant();
}
@@ -0,0 +1 @@
uid://c3dojuv6775r1
@@ -0,0 +1,10 @@
namespace Game.Features.Console.Contracts;
public enum CommandArgType
{
String,
Int,
Float,
Bool,
Choice
}
@@ -0,0 +1 @@
uid://dnt7hwlwqtdyq
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Globalization;
namespace Game.Features.Console.Contracts;
public sealed class CommandArgValues(IReadOnlyList<string> values)
{
public string GetString(int index) => values[index];
public int GetInt(int index) => int.Parse(values[index], CultureInfo.InvariantCulture);
public float GetFloat(int index) => float.Parse(values[index], CultureInfo.InvariantCulture);
public bool GetBool(int index) => bool.Parse(values[index]);
}
@@ -0,0 +1 @@
uid://carkvp7i86mq4
@@ -0,0 +1,40 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public sealed class CommandArgsBuilder
{
private readonly List<CommandArgSpec> _specs = [];
public CommandArgsBuilder AddStringArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.String));
return this;
}
public CommandArgsBuilder AddIntArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Int));
return this;
}
public CommandArgsBuilder AddFloatArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Float));
return this;
}
public CommandArgsBuilder AddBoolArg(string name)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Bool));
return this;
}
public CommandArgsBuilder AddChoiceArg(string name, IReadOnlyList<string> choices)
{
_specs.Add(new CommandArgSpec(name, CommandArgType.Choice, choices));
return this;
}
public IReadOnlyList<CommandArgSpec> Build() => _specs.ToArray();
}
@@ -0,0 +1 @@
uid://cyviyspt0gye6
@@ -0,0 +1,7 @@
namespace Game.Features.Console.Contracts;
public readonly record struct CommandResult(bool Success, string Message, bool ClearOutput = false)
{
public static CommandResult Ok(string message = "", bool clearOutput = false) => new(true, message, clearOutput);
public static CommandResult Fail(string message) => new(false, message);
}
@@ -0,0 +1 @@
uid://bfvfjkitb1hq7
@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public interface ICommand
{
string Name { get; }
string Description { get; }
IReadOnlyList<CommandArgSpec> Args { get; }
CommandResult Execute(CommandArgValues args);
}
@@ -0,0 +1 @@
uid://d18ye7rqu75ej
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace Game.Features.Console.Contracts;
public interface ICommandRegistry
{
IReadOnlyCollection<ICommand> All { get; }
void Register(ICommand command);
bool TryGet(string name, out ICommand command);
}
@@ -0,0 +1 @@
uid://bpw55xoj10toe
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Domain;
public static class CommandArgValidator
{
private static bool ChoicesContain(IReadOnlyList<string> choices, string value) =>
choices.Any(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase));
private static bool MatchesSpec(string rawValue, CommandArgSpec spec) => spec.Type switch
{
CommandArgType.String => true,
CommandArgType.Int => int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _),
CommandArgType.Float => float.TryParse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture, out _),
CommandArgType.Bool => bool.TryParse(rawValue, out _),
CommandArgType.Choice => spec.Choices is not null && ChoicesContain(spec.Choices, rawValue),
_ => throw new ArgumentOutOfRangeException(nameof(spec), spec.Type, null)
};
public static CommandResult Validate(IReadOnlyList<CommandArgSpec> specs, IReadOnlyList<string> arguments)
{
if (arguments.Count != specs.Count)
{
return CommandResult.Fail($"Expected {specs.Count} argument(s), got {arguments.Count}.");
}
for (int i = 0; i < specs.Count; i++)
{
CommandArgSpec spec = specs[i];
string rawValue = arguments[i];
if (MatchesSpec(rawValue, spec)) continue;
return CommandResult.Fail($"Argument '{spec.Name}' expected {spec}, got '{rawValue}'.");
}
return CommandResult.Ok();
}
}
@@ -0,0 +1 @@
uid://dtgsw88f1pejl
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace Game.Features.Console.Domain;
public static class CommandLineParser
{
public static bool TryParse(string input, out string name, out IReadOnlyList<string> arguments)
{
string[] tokens = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0)
{
name = string.Empty;
arguments = [];
return false;
}
name = tokens[0];
arguments = tokens[1..];
return true;
}
}
@@ -0,0 +1 @@
uid://brr37gdqsgb0m
@@ -0,0 +1,83 @@
using Game.Features.Console.Application;
using Game.Features.Console.Contracts;
using Game.Features.Console.Infrastructure;
using Game.Godot;
using Game.Godot.Extensions;
using Game.Godot.Mappings;
using Godot;
namespace Game.Features.Console.Godot;
public partial class ConsoleController : CanvasLayer
{
[Export] public LineEdit CommandInput;
[Export] public RichTextLabel OutputLog;
private CommandExecute _commandExecute = new(new CommandRegistry());
private MouseCapture.Memento _mouseMemento;
private void Toggle()
{
Visible = !Visible;
if (Visible)
{
GamePause.Pause();
_mouseMemento = MouseCapture.SaveCaptureMode();
CommandInput.GrabFocus();
}
else
{
GamePause.Resume();
_mouseMemento.RestoreCaptureMode();
}
}
private void AppendLine(string line)
{
OutputLog.AppendText(line + "\n");
}
public void UseCommandRegistry(ICommandRegistry registry)
{
_commandExecute = new CommandExecute(registry);
}
private void OnInputSubmitted(string text)
{
CommandInput.Clear();
CommandInput.ReleaseFocus();
CommandInput.GrabFocus();
if (text.Length == 0) return;
AppendLine($"> {text}");
CommandResult result = _commandExecute.Execute(text);
if (result.ClearOutput)
{
OutputLog.Clear();
return;
}
if (result.Message.Length > 0)
{
AppendLine(result.Message);
}
}
public override void _Ready()
{
this.AssertRequiredExports();
Visible = false;
GamePause.SetIgnoresPause(this, true);
}
public override void _Input(InputEvent @event)
{
if (!@event.IsActionPressed(InputAction.ToggleConsole.ActionName())) return;
Toggle();
GetViewport().SetInputAsHandled();
}
}
@@ -0,0 +1 @@
uid://ypsdpciag6r6
@@ -0,0 +1,71 @@
[gd_scene format=3 uid="uid://bk0771dvonxh8"]
[ext_resource type="Script" uid="uid://ypsdpciag6r6" path="res://src/Features/Console/Godot/ConsoleController.cs" id="1_script"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_console"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0, 0, 0, 0.85)
[sub_resource type="SystemFont" id="SystemFont_terminal"]
font_names = PackedStringArray("Consolas", "Cascadia Mono", "Courier New")
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_input"]
content_margin_left = 0.0
content_margin_top = 12.0
content_margin_right = 0.0
content_margin_bottom = 0.0
bg_color = Color(0, 0, 0, 0)
[node name="ConsoleController" type="CanvasLayer" unique_id=1711862908 node_paths=PackedStringArray("CommandInput", "OutputLog")]
layer = 100
script = ExtResource("1_script")
CommandInput = NodePath("Panel/Container/InputRow/CommandInput")
OutputLog = NodePath("Panel/Container/OutputLog")
[node name="Panel" type="PanelContainer" parent="." unique_id=515760683]
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 260.0
theme_override_styles/panel = SubResource("StyleBoxFlat_console")
[node name="Container" type="VBoxContainer" parent="Panel" unique_id=37696848]
layout_mode = 2
theme_override_constants/separation = 0
[node name="OutputLog" type="RichTextLabel" parent="Panel/Container" unique_id=1318894518]
custom_minimum_size = Vector2(0, 220)
layout_mode = 2
size_flags_vertical = 3
theme_override_fonts/normal_font = SubResource("SystemFont_terminal")
theme_override_fonts/bold_font = SubResource("SystemFont_terminal")
theme_override_fonts/italics_font = SubResource("SystemFont_terminal")
theme_override_fonts/mono_font = SubResource("SystemFont_terminal")
scroll_following = true
[node name="InputRow" type="HBoxContainer" parent="Panel/Container" unique_id=1183500371]
layout_mode = 2
theme_override_constants/separation = 4
[node name="PromptMargin" type="MarginContainer" parent="Panel/Container/InputRow" unique_id=1356750045]
layout_mode = 2
theme_override_constants/margin_top = 12
[node name="PromptLabel" type="Label" parent="Panel/Container/InputRow/PromptMargin" unique_id=429447266]
layout_mode = 2
theme_override_fonts/font = SubResource("SystemFont_terminal")
text = ">"
[node name="CommandInput" type="LineEdit" parent="Panel/Container/InputRow" unique_id=209466737]
layout_mode = 2
size_flags_horizontal = 3
theme_override_fonts/font = SubResource("SystemFont_terminal")
theme_override_styles/normal = SubResource("StyleBoxFlat_input")
theme_override_styles/read_only = SubResource("StyleBoxFlat_input")
theme_override_styles/focus = SubResource("StyleBoxFlat_input")
keep_editing_on_text_submit = true
caret_blink = true
[connection signal="text_submitted" from="Panel/Container/InputRow/CommandInput" to="." method="OnInputSubmitted"]
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using Game.Features.Console.Contracts;
namespace Game.Features.Console.Infrastructure;
public sealed class CommandRegistry : ICommandRegistry
{
private readonly Dictionary<string, ICommand> _commands = new();
public IReadOnlyCollection<ICommand> All => _commands.Values;
public void Register(ICommand command)
{
_commands[command.Name] = command;
}
public bool TryGet(string name, out ICommand command) => _commands.TryGetValue(name, out command);
}
@@ -0,0 +1 @@
uid://cfcp8d8sh0gf5
@@ -1,5 +1,6 @@
using System.Linq; using System.Linq;
using Game.Features.Players.Domain; using Game.Features.Players.Domain;
using Game.Godot;
using Game.Godot.Extensions; using Game.Godot.Extensions;
using Game.Godot.Mappings; using Game.Godot.Mappings;
using Godot; using Godot;
@@ -19,8 +20,6 @@ public partial class PlayerController : Node3D
private PlayerMovement _movement; private PlayerMovement _movement;
private PlayerLook _look; private PlayerLook _look;
private static bool IsMouseCaptured() => Input.MouseMode == Input.MouseModeEnum.Captured;
public override void _Ready() public override void _Ready()
{ {
this.AssertRequiredExports(); this.AssertRequiredExports();
@@ -32,7 +31,7 @@ public partial class PlayerController : Node3D
public override void _UnhandledInput(InputEvent @event) public override void _UnhandledInput(InputEvent @event)
{ {
if (@event is not InputEventMouseMotion mouseMotion || !IsMouseCaptured()) return; if (@event is not InputEventMouseMotion mouseMotion || !MouseCapture.IsCaptured) return;
Body.RotateY(_look.CalculateYawDelta(mouseMotion.Relative.X)); Body.RotateY(_look.CalculateYawDelta(mouseMotion.Relative.X));
_look.UpdatePitch(mouseMotion.Relative.Y); _look.UpdatePitch(mouseMotion.Relative.Y);
-27
View File
@@ -1,27 +0,0 @@
using Game.Godot.Mappings;
using Godot;
namespace Game.Godot.Bootstrap;
public partial class GameBootstrap : Node
{
private static void ToggleMouseCapture()
{
Input.MouseMode = Input.MouseMode == Input.MouseModeEnum.Captured
? Input.MouseModeEnum.Visible
: Input.MouseModeEnum.Captured;
}
public override void _Ready()
{
Input.MouseMode = Input.MouseModeEnum.Captured;
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsActionPressed(InputAction.Cancel.ActionName()))
{
ToggleMouseCapture();
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using Game.Features.Console.Application.Commands;
using Game.Features.Console.Godot;
using Game.Features.Console.Infrastructure;
using Game.Godot.Extensions;
using Game.Godot.Mappings;
using Godot;
namespace Game.Godot;
public partial class GameBootstrap : Node
{
[Export] public ConsoleController Console;
private void RegisterConsoleCommands()
{
CommandRegistry registry = new();
registry.Register(new HelpCommand(registry));
registry.Register(new EchoCommand());
registry.Register(new ClearCommand());
Console.UseCommandRegistry(registry);
}
public override void _Ready()
{
this.AssertRequiredExports();
MouseCapture.Capture();
RegisterConsoleCommands();
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsActionPressed(InputAction.Cancel.ActionName()))
{
MouseCapture.Toggle();
}
}
}
+29
View File
@@ -0,0 +1,29 @@
using Godot;
namespace Game.Godot;
public static class GamePause
{
private static SceneTree Tree => (SceneTree)Engine.GetMainLoop();
public static bool IsPaused => Tree.Paused;
public static void Pause() => Tree.Paused = true;
public static void Resume() => Tree.Paused = false;
public static void Toggle()
{
if (IsPaused)
{
Resume();
}
else
{
Pause();
}
}
public static void SetIgnoresPause(Node node, bool value) =>
node.ProcessMode = value ? Node.ProcessModeEnum.Always : Node.ProcessModeEnum.Inherit;
}
+1
View File
@@ -0,0 +1 @@
uid://i7fuhrb208r8
+3 -1
View File
@@ -9,7 +9,8 @@ public enum InputAction
MoveForward, MoveForward,
MoveBack, MoveBack,
Jump, Jump,
Cancel Cancel,
ToggleConsole
} }
public static class InputActionExtensions public static class InputActionExtensions
@@ -25,6 +26,7 @@ public static class InputActionExtensions
InputAction.MoveBack => "move_back", InputAction.MoveBack => "move_back",
InputAction.Jump => "jump", InputAction.Jump => "jump",
InputAction.Cancel => "ui_cancel", InputAction.Cancel => "ui_cancel",
InputAction.ToggleConsole => "toggle_console",
_ => throw new ArgumentOutOfRangeException(nameof(action), action, null) _ => throw new ArgumentOutOfRangeException(nameof(action), action, null)
}; };
} }
+42
View File
@@ -0,0 +1,42 @@
using Godot;
namespace Game.Godot;
public static class MouseCapture
{
public static bool IsCaptured => Input.MouseMode == Input.MouseModeEnum.Captured;
public static void Capture() => Input.MouseMode = Input.MouseModeEnum.Captured;
public static void Release() => Input.MouseMode = Input.MouseModeEnum.Visible;
public static void Toggle()
{
if (IsCaptured)
{
Release();
}
else
{
Capture();
}
}
public static Memento SaveCaptureMode()
{
Memento memento = new(IsCaptured);
Release();
return memento;
}
public sealed class Memento(bool wasCaptured)
{
public void RestoreCaptureMode()
{
if (wasCaptured)
{
Capture();
}
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://dab3etxieot78
@@ -1,9 +1,10 @@
[gd_scene format=3 uid="uid://c44pljehe0qun"] [gd_scene format=3 uid="uid://c44pljehe0qun"]
[ext_resource type="Texture2D" uid="uid://b5i827vmueyei" path="res://assets/textures/skyboxes/skybox_emx_19.png" id="1_g4n7o"] [ext_resource type="Texture2D" uid="uid://b5i827vmueyei" path="res://assets/textures/skyboxes/skybox_emx_19.png" id="1_g4n7o"]
[ext_resource type="PackedScene" uid="uid://curuqmbndymi6" path="res://src/Scenes/FloorCeilingHr42.tscn" id="2_1kpcd"] [ext_resource type="PackedScene" uid="uid://curuqmbndymi6" path="res://src/Godot/Scenes/FloorCeilingHr42.tscn" id="2_1kpcd"]
[ext_resource type="PackedScene" uid="uid://bka1dmgdi5btd" path="res://src/Features/Player/Godot/PlayerNode.tscn" id="2_mvtit"] [ext_resource type="PackedScene" uid="uid://bka1dmgdi5btd" path="res://src/Features/Player/Godot/PlayerNode.tscn" id="2_mvtit"]
[ext_resource type="Script" uid="uid://drctu5y21ey2y" path="res://src/Godot/Bootstrap/GameBootstrap.cs" id="3_bootstrap"] [ext_resource type="Script" uid="uid://drctu5y21ey2y" path="res://src/Godot/GameBootstrap.cs" id="3_bootstrap"]
[ext_resource type="PackedScene" path="res://src/Features/Console/Godot/ConsoleController.tscn" id="4_console"]
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_epykj"] [sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_epykj"]
panorama = ExtResource("1_g4n7o") panorama = ExtResource("1_g4n7o")
@@ -16,8 +17,9 @@ background_mode = 2
sky = SubResource("Sky_1kpcd") sky = SubResource("Sky_1kpcd")
glow_enabled = true glow_enabled = true
[node name="GameWorld" type="Node" unique_id=222644234] [node name="GameWorld" type="Node" unique_id=222644234 node_paths=PackedStringArray("Console")]
script = ExtResource("3_bootstrap") script = ExtResource("3_bootstrap")
Console = NodePath("Console")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1669211403] [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1669211403]
transform = Transform3D(-0.8660254, -0.43301278, 0.25, 0, 0.49999997, 0.86602545, -0.50000006, 0.75, -0.43301266, 0, 0, 0) transform = Transform3D(-0.8660254, -0.43301278, 0.25, 0, 0.49999997, 0.86602545, -0.50000006, 0.75, -0.43301266, 0, 0, 0)
@@ -29,6 +31,8 @@ environment = SubResource("Environment_mvtit")
[node name="Player" parent="." unique_id=26823676 instance=ExtResource("2_mvtit")] [node name="Player" parent="." unique_id=26823676 instance=ExtResource("2_mvtit")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
[node name="Console" parent="." instance=ExtResource("4_console")]
[node name="Entities" type="Node3D" parent="." unique_id=851492709] [node name="Entities" type="Node3D" parent="." unique_id=851492709]
[node name="floor_ceiling_hr_46" parent="Entities" unique_id=33949496 instance=ExtResource("2_1kpcd")] [node name="floor_ceiling_hr_46" parent="Entities" unique_id=33949496 instance=ExtResource("2_1kpcd")]
+1
View File
@@ -0,0 +1 @@
uid://bpubrm4kms5hy