fix(ui): restore retail skill training confirmation
Route untrained-skill promotion through DialogFactory with retail's exact prompt and stored skill/credit properties. Defer the character-sheet refresh until accept or reject completes while leaving trained-skill XP raises immediate. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
66bdae7a83
commit
85980fe13f
7 changed files with 341 additions and 14 deletions
|
|
@ -0,0 +1,79 @@
|
|||
# Retail skill-training confirmation
|
||||
|
||||
Date: 2026-07-13
|
||||
|
||||
## Scope
|
||||
|
||||
This note covers the confirmation shown when an untrained skill is promoted to
|
||||
trained by spending skill credits. It does **not** apply when experience is
|
||||
added to an already trained or specialized skill; those raises are sent
|
||||
immediately.
|
||||
|
||||
## Named retail sources
|
||||
|
||||
- `gmSkillUI::TrainSkillDialogCallback @ 0x0049C210`
|
||||
- `gmSkillUI::TrainSkill @ 0x0049C5F0`
|
||||
- `gmSkillUI::RaiseSelection @ 0x0049C8C0`
|
||||
- `DialogFactory::MakeCallbackDialogInCurrentUI @ 0x00478430`
|
||||
- literal `data_7A7864`: `Are you sure you want to spend %d credits to train %s?`
|
||||
|
||||
The dialog property keys are shared with the existing DialogFactory port:
|
||||
|
||||
```text
|
||||
0x8E dialog type (1 = ConfirmationDialog)
|
||||
0x92 confirmation result written by ConfirmationDialog
|
||||
0xC5 message StringInfo
|
||||
0x10000040 selected skill id
|
||||
0x10000041 skill-credit cost
|
||||
```
|
||||
|
||||
## Pseudocode
|
||||
|
||||
```text
|
||||
gmSkillUI.TrainSkill(playerQualities):
|
||||
if selectedSkill == 0:
|
||||
return false
|
||||
|
||||
skillName = SkillSystem.InqSkillName(selectedSkill)
|
||||
creditCost = GetCostToRaise(playerQualities)
|
||||
message = format(
|
||||
"Are you sure you want to spend %d credits to train %s?",
|
||||
creditCost,
|
||||
skillName)
|
||||
|
||||
data = PropertyCollection()
|
||||
data[0x8E] = 1 // ConfirmationDialog
|
||||
data[0xC5] = message
|
||||
data[0x10000040] = selectedSkill
|
||||
data[0x10000041] = creditCost
|
||||
DialogFactory.MakeCallbackDialogInCurrentUI(
|
||||
data,
|
||||
gmSkillUI.TrainSkillDialogCallback)
|
||||
return true
|
||||
|
||||
gmSkillUI.TrainSkillDialogCallback(data):
|
||||
if data.bool(0x92) is not true:
|
||||
return
|
||||
|
||||
skillId = data.uint(0x10000040)
|
||||
creditCost = data.uint(0x10000041)
|
||||
if skillId != 0 and creditCost != 0:
|
||||
CM_Train.Event_TrainSkillAdvancementClass(skillId, creditCost)
|
||||
```
|
||||
|
||||
`gmSkillUI::RaiseSelection` branches on advancement class. Untrained skills
|
||||
enter `TrainSkill`; trained/specialized skills call `CM_Train::Event_TrainSkill`
|
||||
directly with the XP cost. Therefore a confirmation on every skill XP raise
|
||||
would be a retail divergence.
|
||||
|
||||
## Cross-reference
|
||||
|
||||
- ACE `GameActionTrainSkill.Handle` reads exactly two fields, skill id and
|
||||
credits spent, then calls `HandleActionTrainSkill`.
|
||||
- Chorizite's generated native client bindings expose
|
||||
`CM_Train::Event_TrainSkillAdvancementClass(UInt32 skill, UInt32 credits)` and
|
||||
separately expose `CM_Train::Event_TrainSkill(UInt32 skill, UInt32 xp)`.
|
||||
|
||||
These corroborate the callback's two stored properties and the distinction
|
||||
between training a skill and adding XP to a trained skill. The named retail
|
||||
client remains the behavioral oracle for when the dialog appears.
|
||||
|
|
@ -195,6 +195,14 @@ public static class CharacterStatController
|
|||
long Cost,
|
||||
int Amount);
|
||||
|
||||
/// <summary>
|
||||
/// Handles a retail stat raise and invokes <paramref name="completed"/> when
|
||||
/// the request has either been applied or cancelled. The completion seam is
|
||||
/// required by asynchronous retail confirmation dialogs such as
|
||||
/// <c>gmSkillUI::TrainSkill @ 0x0049C5F0</c>.
|
||||
/// </summary>
|
||||
public delegate void RaiseRequestHandler(RaiseRequest request, Action completed);
|
||||
|
||||
private sealed class TabVisual
|
||||
{
|
||||
public required CharacterStatTab Tab { get; init; }
|
||||
|
|
@ -248,7 +256,7 @@ public static class CharacterStatController
|
|||
UiDatFont? datFont = null,
|
||||
UiDatFont? rowDatFont = null,
|
||||
Func<uint, (uint handle, int w, int h)>? spriteResolve = null,
|
||||
Action<RaiseRequest>? onRaiseRequest = null,
|
||||
RaiseRequestHandler? onRaiseRequest = null,
|
||||
Action? onClose = null)
|
||||
{
|
||||
// rowDatFont: larger font for attribute row name/value text (18px vs 16px default).
|
||||
|
|
@ -1129,7 +1137,7 @@ public static class CharacterStatController
|
|||
int[] attrSel,
|
||||
int[] skillSel,
|
||||
Func<IReadOnlyList<SkillRowBinding>> skillRows,
|
||||
Action<RaiseRequest>? onRaiseRequest,
|
||||
RaiseRequestHandler? onRaiseRequest,
|
||||
Action<uint?>? afterRaiseRequest)
|
||||
{
|
||||
foreach (var button in allRaise1)
|
||||
|
|
@ -1154,7 +1162,7 @@ public static class CharacterStatController
|
|||
int[] attrSel,
|
||||
int[] skillSel,
|
||||
Func<IReadOnlyList<SkillRowBinding>> skillRows,
|
||||
Action<RaiseRequest>? onRaiseRequest,
|
||||
RaiseRequestHandler? onRaiseRequest,
|
||||
Action<uint?>? afterRaiseRequest)
|
||||
{
|
||||
if (onRaiseRequest is null) return;
|
||||
|
|
@ -1180,8 +1188,7 @@ public static class CharacterStatController
|
|||
|
||||
if (request is not { } value) return;
|
||||
|
||||
onRaiseRequest(value);
|
||||
afterRaiseRequest?.Invoke(selectedSkillId);
|
||||
onRaiseRequest(value, () => afterRaiseRequest?.Invoke(selectedSkillId));
|
||||
}
|
||||
|
||||
private static RaiseRequest? TryBuildAttributeRaiseRequest(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public static class RetailDialogProperty
|
|||
public const uint QueueKey = 0xC3u;
|
||||
public const uint Message = 0xC5u;
|
||||
public const uint UsageObjectId = 0x1000003Du;
|
||||
public const uint TrainSkillId = 0x10000040u;
|
||||
public const uint TrainSkillCredits = 0x10000041u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Ports the untrained-skill confirmation owned by
|
||||
/// <c>gmSkillUI::TrainSkill @ 0x0049C5F0</c> and
|
||||
/// <c>gmSkillUI::TrainSkillDialogCallback @ 0x0049C210</c>.
|
||||
/// Already-trained skill XP raises deliberately bypass this controller, matching
|
||||
/// retail's separate <c>gmSkillUI::RaiseSelection @ 0x0049C8C0</c> path.
|
||||
/// </summary>
|
||||
public sealed class RetailSkillTrainingConfirmationController
|
||||
{
|
||||
public const string MessageFormat =
|
||||
"Are you sure you want to spend {0} credits to train {1}?";
|
||||
|
||||
private readonly RetailDialogFactory _dialogs;
|
||||
|
||||
public RetailSkillTrainingConfirmationController(RetailDialogFactory dialogs)
|
||||
=> _dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs));
|
||||
|
||||
public uint Request(
|
||||
CharacterStatController.RaiseRequest request,
|
||||
CharacterSheet sheet,
|
||||
Action<CharacterStatController.RaiseRequest> accepted,
|
||||
Action completed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sheet);
|
||||
ArgumentNullException.ThrowIfNull(accepted);
|
||||
ArgumentNullException.ThrowIfNull(completed);
|
||||
|
||||
if (request.Kind != CharacterStatController.RaiseTargetKind.TrainSkill)
|
||||
throw new ArgumentException("Only TrainSkill requests require this confirmation.", nameof(request));
|
||||
if (request.Cost <= 0 || request.Cost > uint.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(request), "Training cost must fit retail's uint32 field.");
|
||||
|
||||
CharacterSkill skill = sheet.Skills.FirstOrDefault(candidate => candidate.Id == request.StatId)
|
||||
?? throw new InvalidOperationException($"Skill {request.StatId} is absent from the character sheet.");
|
||||
|
||||
string message = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
MessageFormat,
|
||||
request.Cost,
|
||||
skill.Name);
|
||||
|
||||
RetailDialogData data = RetailDialogData.Confirmation(message)
|
||||
.Set(RetailDialogProperty.TrainSkillId, request.StatId)
|
||||
.Set(RetailDialogProperty.TrainSkillCredits, checked((uint)request.Cost));
|
||||
|
||||
return _dialogs.MakeDialog(data, result =>
|
||||
{
|
||||
if (result.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
||||
{
|
||||
uint skillId = result.GetUInt32(RetailDialogProperty.TrainSkillId);
|
||||
uint credits = result.GetUInt32(RetailDialogProperty.TrainSkillCredits);
|
||||
if (skillId != 0u && credits != 0u)
|
||||
{
|
||||
accepted(new CharacterStatController.RaiseRequest(
|
||||
CharacterStatController.RaiseTargetKind.TrainSkill,
|
||||
skillId,
|
||||
credits,
|
||||
Amount: 1));
|
||||
}
|
||||
}
|
||||
|
||||
completed();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -142,6 +142,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private readonly RetailUiAutomationScriptRunner? _automation;
|
||||
private GameplayConfirmationController? _gameplayConfirmationController;
|
||||
private RetailItemConfirmationController? _itemConfirmationController;
|
||||
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
|
||||
private bool _disposed;
|
||||
|
||||
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
|
||||
|
|
@ -707,6 +708,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_itemConfirmationController = new RetailItemConfirmationController(
|
||||
DialogFactory,
|
||||
ItemInteraction);
|
||||
_skillTrainingConfirmationController =
|
||||
new RetailSkillTrainingConfirmationController(DialogFactory);
|
||||
Console.WriteLine(
|
||||
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
|
||||
}
|
||||
|
|
@ -806,7 +809,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
provider.HandleRaiseRequest,
|
||||
(request, completed) => HandleCharacterRaise(provider, request, completed),
|
||||
() => CloseWindow(WindowNames.Character));
|
||||
RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
|
|
@ -826,6 +829,30 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
|
||||
}
|
||||
|
||||
private void HandleCharacterRaise(
|
||||
CharacterSheetProvider provider,
|
||||
CharacterStatController.RaiseRequest request,
|
||||
Action completed)
|
||||
{
|
||||
if (request.Kind != CharacterStatController.RaiseTargetKind.TrainSkill)
|
||||
{
|
||||
provider.HandleRaiseRequest(request);
|
||||
completed();
|
||||
return;
|
||||
}
|
||||
|
||||
RetailSkillTrainingConfirmationController confirmations =
|
||||
_skillTrainingConfirmationController
|
||||
?? throw new InvalidOperationException(
|
||||
"The retail dialog factory must be mounted before the character panel.");
|
||||
|
||||
confirmations.Request(
|
||||
request,
|
||||
provider.BuildSheet(),
|
||||
provider.HandleRaiseRequest,
|
||||
completed);
|
||||
}
|
||||
|
||||
private void MountPlugins()
|
||||
{
|
||||
if (_bindings.Plugins is null) return;
|
||||
|
|
|
|||
|
|
@ -674,7 +674,8 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
btn1.OnClick!();
|
||||
|
|
@ -696,7 +697,8 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
Assert.Equal("Normal", btn1.ActiveState);
|
||||
|
|
@ -717,7 +719,8 @@ public class CharacterStatControllerTests
|
|||
(CharacterStatController.ListBoxId, list));
|
||||
var requests = new List<CharacterStatController.RaiseRequest>();
|
||||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: requests.Add);
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[6].OnClick!();
|
||||
btn1.OnClick!();
|
||||
|
|
@ -745,7 +748,8 @@ public class CharacterStatControllerTests
|
|||
AttributeRaise10Costs = new long[] { 0L, 0L, 0L, 0L, 1_100L, 0L, 0L, 0L, 0L },
|
||||
};
|
||||
|
||||
CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: requests.Add);
|
||||
CharacterStatController.Bind(layout, () => sheet,
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
list.Children.OfType<UiClickablePanel>().ToList()[4].OnClick!();
|
||||
btn10.OnClick!();
|
||||
|
|
@ -1004,7 +1008,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16),
|
||||
onRaiseRequest: requests.Add);
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
SkillRows(list)[1].OnClick!();
|
||||
|
|
@ -1063,7 +1067,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||
spriteResolve: id => (id, 16, 16),
|
||||
onRaiseRequest: requests.Add);
|
||||
onRaiseRequest: (request, completed) => { requests.Add(request); completed(); });
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
SkillRows(list)[5].OnClick!();
|
||||
|
|
@ -1107,7 +1111,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
CharacterStatController.Bind(layout, () => sheet,
|
||||
spriteResolve: id => (id, 16, 16),
|
||||
onRaiseRequest: request =>
|
||||
onRaiseRequest: (request, completed) =>
|
||||
{
|
||||
requests.Add(request);
|
||||
sheet = new CharacterSheet
|
||||
|
|
@ -1127,6 +1131,7 @@ public class CharacterStatControllerTests
|
|||
Raise10Cost: 100),
|
||||
},
|
||||
};
|
||||
completed();
|
||||
});
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
|
|
@ -1144,6 +1149,58 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal("Normal", btn10.ActiveState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_DeferredTrainRefreshesOnlyWhenRequestCompletes()
|
||||
{
|
||||
var list = new UiPanel { Width = 300 };
|
||||
var btn1 = MakeButton();
|
||||
var btn10 = MakeButton();
|
||||
CharacterSheet sheet = TrainingSheet(CharacterSkillAdvancementClass.Untrained);
|
||||
var layout = Fake(
|
||||
(CharacterStatController.ListBoxId, list),
|
||||
(CharacterStatController.RaiseOneId, btn1),
|
||||
(CharacterStatController.RaiseTenId, btn10));
|
||||
Action? completeRaise = null;
|
||||
|
||||
CharacterStatController.Bind(layout, () => sheet,
|
||||
spriteResolve: id => (id, 16, 16),
|
||||
onRaiseRequest: (_, completed) => completeRaise = completed);
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
SkillRows(list).Single().OnClick!();
|
||||
btn1.OnClick!();
|
||||
sheet = TrainingSheet(CharacterSkillAdvancementClass.Trained);
|
||||
|
||||
Assert.NotNull(completeRaise);
|
||||
Assert.False(btn10.Visible);
|
||||
|
||||
completeRaise!();
|
||||
|
||||
Assert.True(btn10.Visible);
|
||||
|
||||
static CharacterSheet TrainingSheet(CharacterSkillAdvancementClass advancement)
|
||||
=> new()
|
||||
{
|
||||
SkillCredits = advancement == CharacterSkillAdvancementClass.Untrained ? 10 : 6,
|
||||
UnassignedXp = 1_000,
|
||||
Skills =
|
||||
[
|
||||
new CharacterSkill(
|
||||
100u,
|
||||
"Train Me",
|
||||
0x06000001u,
|
||||
advancement,
|
||||
BaseLevel: 5,
|
||||
CurrentLevel: 5,
|
||||
UsableUntrained: true,
|
||||
TrainedCost: 4,
|
||||
SpecializedCost: 0,
|
||||
RaiseCost: advancement == CharacterSkillAdvancementClass.Untrained ? 0 : 10,
|
||||
Raise10Cost: advancement == CharacterSkillAdvancementClass.Untrained ? 0 : 100),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillsTab_BindsCharacterScrollbarToScrollableViewport()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.Tests.UI.Layout;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class RetailSkillTrainingConfirmationControllerTests
|
||||
{
|
||||
private const uint Healing = 21u;
|
||||
|
||||
[Fact]
|
||||
public void AcceptSendsStoredSkillAndCreditsOnlyAfterConfirmation()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
ImportedLayout? shown = null;
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
shown = FixtureLoader.LoadConfirmationDialog());
|
||||
var controller = new RetailSkillTrainingConfirmationController(factory);
|
||||
var sent = new List<CharacterStatController.RaiseRequest>();
|
||||
bool completed = false;
|
||||
|
||||
controller.Request(Request(), Sheet(), sent.Add, () => completed = true);
|
||||
|
||||
Assert.Empty(sent);
|
||||
Assert.False(completed);
|
||||
Assert.Equal(
|
||||
"Are you sure you want to spend 6 credits to train Healing?",
|
||||
string.Join(" ", Assert.IsType<UiText>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
||||
|
||||
Assert.IsType<UiButton>(shown.FindElement(
|
||||
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
||||
|
||||
CharacterStatController.RaiseRequest request = Assert.Single(sent);
|
||||
Assert.Equal(CharacterStatController.RaiseTargetKind.TrainSkill, request.Kind);
|
||||
Assert.Equal(Healing, request.StatId);
|
||||
Assert.Equal(6L, request.Cost);
|
||||
Assert.Equal(1, request.Amount);
|
||||
Assert.True(completed);
|
||||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectCompletesWithoutSendingTrainingRequest()
|
||||
{
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
ImportedLayout? shown = null;
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
shown = FixtureLoader.LoadConfirmationDialog());
|
||||
var controller = new RetailSkillTrainingConfirmationController(factory);
|
||||
var sent = new List<CharacterStatController.RaiseRequest>();
|
||||
bool completed = false;
|
||||
|
||||
controller.Request(Request(), Sheet(), sent.Add, () => completed = true);
|
||||
Assert.IsType<UiButton>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.RejectButtonId)).OnClick!();
|
||||
|
||||
Assert.Empty(sent);
|
||||
Assert.True(completed);
|
||||
Assert.False(factory.IsOpen);
|
||||
}
|
||||
|
||||
private static CharacterStatController.RaiseRequest Request()
|
||||
=> new(CharacterStatController.RaiseTargetKind.TrainSkill, Healing, 6L, 1);
|
||||
|
||||
private static CharacterSheet Sheet()
|
||||
=> new()
|
||||
{
|
||||
SkillCredits = 10,
|
||||
Skills =
|
||||
[
|
||||
new CharacterSkill(
|
||||
Healing,
|
||||
"Healing",
|
||||
0x06000133u,
|
||||
CharacterSkillAdvancementClass.Untrained,
|
||||
BaseLevel: 10,
|
||||
CurrentLevel: 10,
|
||||
UsableUntrained: true,
|
||||
TrainedCost: 6,
|
||||
SpecializedCost: 10,
|
||||
RaiseCost: 0L),
|
||||
],
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue