diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a9f1414..52d9dd2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -53,6 +53,12 @@ jobs:
runs-on: ubuntu-latest
steps:
+ # checkout the repo so gh commands work
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
- name: Set release as latest
run: gh release edit ${{ env.RELEASE_TAG }} --draft=false --latest
env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34bd41a..7e486df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `Fixed` for any bug fixes.
- `Security` in case of vulnerabilities.
+## [0.5.0] - 2025-07-27
+
+### Added
+
+- Added a new Challenge system to provide a mechanism to add extra challenges for players. This system is still in early stages, so currently only offers kill challenges.
+- Added support for disabling "+XX Experience" scrolling combat text messages (via in-game `.xpconf` command)
+- Added support for displaying current player buffs provided by XPRising
+
+### Fixed
+
+- Improved the display of heat > 6 stars. It will now show the heat number above that stage, as there is no maximum. This allows users to see the value as it drops.
+
## [0.4.10] - 2025-06-04
### Added
diff --git a/ClientUI/ClientUI.csproj b/ClientUI/ClientUI.csproj
index f050716..6f87569 100644
--- a/ClientUI/ClientUI.csproj
+++ b/ClientUI/ClientUI.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/ClientUI/Plugin.cs b/ClientUI/Plugin.cs
index 784bfe9..4aa8ac6 100644
--- a/ClientUI/Plugin.cs
+++ b/ClientUI/Plugin.cs
@@ -19,7 +19,8 @@ public class Plugin : BasePlugin
{
private static ManualLogSource _logger;
internal static Plugin Instance { get; private set; }
-
+
+ private const string ConnectionGroup = "InternalConnection";
private static FrameTimer _uiInitialisedTimer = new();
private static FrameTimer _connectUiTimer;
private static FrameTimer _connectionUpdateTimer;
@@ -73,7 +74,7 @@ public override void Load()
_connectionProgressValue = (_connectionProgressValue + increment) % 100.0f;
UIManager.ContentPanel.ChangeProgress(new ProgressSerialisedMessage()
{
- Group = "Connection",
+ Group = ConnectionGroup,
Label = "Connecting",
Colour = TurboColourMap,
Active = ProgressSerialisedMessage.ActiveState.Active,
@@ -87,7 +88,7 @@ public override void Load()
{
UIManager.ContentPanel.ChangeProgress(new ProgressSerialisedMessage()
{
- Group = "Connection",
+ Group = ConnectionGroup,
Label = "Connecting",
Colour = "red",
Active = ProgressSerialisedMessage.ActiveState.Active,
@@ -99,7 +100,7 @@ public override void Load()
UIManager.ContentPanel.SetButton(new ActionSerialisedMessage()
{
- Group = "Connection",
+ Group = ConnectionGroup,
ID = "RetryConnection",
Label = "Retry Connection?",
Colour = "red",
@@ -113,7 +114,7 @@ public override void Load()
UIManager.ContentPanel.SetButton(new ActionSerialisedMessage()
{
- Group = "Connection",
+ Group = ConnectionGroup,
ID = "HideUI",
Label = "Hide UI",
Colour = "red",
@@ -125,7 +126,7 @@ public override void Load()
UIManager.SetActive(false);
});
- UIManager.ContentPanel.OpenActionPanel();
+ UIManager.ContentPanel.OpenActionPanel(ConnectionGroup);
}
},
TimeSpan.FromMilliseconds(50),
@@ -205,6 +206,11 @@ private static void RegisterMessages()
UIManager.Reset();
Log(LogLevel.Info, $"Client initialisation successful");
});
+ ChatService.RegisterType(((message, steamId) =>
+ {
+ if (message.Reset) UIManager.TextPanel.SetText(message.Title, message.Text);
+ else UIManager.TextPanel.AddText(message.Text);
+ }));
}
public new static void Log(LogLevel level, string message)
diff --git a/ClientUI/UI/Panel/ActionPanel.cs b/ClientUI/UI/Panel/ActionPanel.cs
index 81b40a4..5654de3 100644
--- a/ClientUI/UI/Panel/ActionPanel.cs
+++ b/ClientUI/UI/Panel/ActionPanel.cs
@@ -1,3 +1,4 @@
+using ClientUI.UI.Util;
using UnityEngine;
using UnityEngine.UI;
using XPShared.Transport.Messages;
@@ -8,15 +9,44 @@ namespace ClientUI.UI.Panel;
public class ActionPanel
{
+ private const string ExpandText = "<";
+ private const string ContractText = ">";
+ private static readonly ColorBlock ClosedButtonColour = UIFactory.CreateColourBlock(Colour.SliderFill);
+ private static readonly ColorBlock OpenButtonColour = UIFactory.CreateColourBlock(Colour.SliderHandle);
+
private readonly GameObject _contentRoot;
+ private readonly GameObject _actionsContent;
+ private readonly GameObject _buttonsContent;
+
+ private readonly Dictionary _actionGroups = new();
+ private readonly Dictionary _actions = new();
+
+ private string _activeGroup = "";
+
public ActionPanel(GameObject root)
{
_contentRoot = root;
+
+ _buttonsContent = UIFactory.CreateUIObject("ButtonsContent", _contentRoot);
+ UIFactory.SetLayoutGroup(_buttonsContent, false, false, true, true, 2, 0, 0, 0, 0, TextAnchor.UpperRight);
+ UIFactory.SetLayoutElement(_buttonsContent, ignoreLayout: true);
+
+ // Set anchor/pivot to top right so it attaches to the root from that side
+ var buttonGroupRect = _buttonsContent.GetComponent();
+ buttonGroupRect.SetAnchors(RectExtensions.PivotPresets.TopRight);
+ buttonGroupRect.SetPivot(RectExtensions.PivotPresets.TopRight);
+
+ _actionsContent = UIFactory.CreateUIObject("ActionsContent", _contentRoot);
+ UIFactory.SetLayoutGroup(_actionsContent, false, false, true, true, 2, 0, 0, 0, 0, TextAnchor.UpperRight);
+ UIFactory.SetLayoutElement(_actionsContent, ignoreLayout: true);
+ var actionRect = _actionsContent.GetComponent();
+ actionRect.anchorMin = Vector2.up;
+ actionRect.anchorMax = Vector2.up;
+ actionRect.pivot = Vector2.one;
+ actionRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, 200);
+ actionRect.Translate(Vector3.left * 50);
}
- private readonly Dictionary _buttonGroups = new();
- private readonly Dictionary _buttons = new();
-
public bool Active
{
get => _contentRoot.active;
@@ -25,10 +55,10 @@ public bool Active
public void SetButton(ActionSerialisedMessage data, Action onClick = null)
{
- if (!_buttons.TryGetValue(data.ID, out var button))
+ if (!_actions.TryGetValue(data.ID, out var button))
{
button = AddButton(data.Group, data.ID, data.Label, data.Colour);
- _buttons[data.ID] = button;
+ _actions[data.ID] = button;
if (onClick == null)
{
button.OnClick = () =>
@@ -49,26 +79,98 @@ public void SetButton(ActionSerialisedMessage data, Action onClick = null)
internal void Reset()
{
- foreach (var (_, buttonGroup) in _buttonGroups)
+ foreach (var (_, group) in _actionGroups)
{
- GameObject.Destroy(buttonGroup);
+ GameObject.Destroy(group.Item1);
+ GameObject.Destroy(group.Item2.GameObject);
}
- _buttonGroups.Clear();
- _buttons.Clear();
+ _actionGroups.Clear();
+ _actions.Clear();
}
private ButtonRef AddButton(string group, string id, string text, string colour)
{
- if (!_buttonGroups.TryGetValue(group, out var buttonGroup))
+ if (!_actionGroups.TryGetValue(group, out var buttonGroup))
{
- buttonGroup = UIFactory.CreateUIObject(group, _contentRoot);
- UIFactory.SetLayoutGroup(buttonGroup, false, false, true, true, 3);
- _buttonGroups.Add(group, buttonGroup);
+ // Set up the button that will open this group
+ var groupButton = UIFactory.CreateButton(_buttonsContent, $"{group}-button", ContractText);
+ UIFactory.SetLayoutElement(groupButton.GameObject, minHeight: 25, minWidth: 25, flexibleWidth: 0, flexibleHeight: 0);
+ groupButton.OnClick = () => ToggleGroup(group);
+
+ // actionGroup parented to groupButton
+ var actionGroup = UIFactory.CreateUIObject(group, groupButton.GameObject);
+ UIFactory.SetLayoutGroup(actionGroup, false, false, true, true, 3, 0, 0, 0, 0, TextAnchor.UpperRight);
+ UIFactory.SetLayoutElement(actionGroup, ignoreLayout: true);
+ var actionRect = actionGroup.GetComponent();
+ actionRect.anchorMin = Vector2.up;
+ actionRect.anchorMax = Vector2.up;
+ actionRect.pivot = Vector2.one;
+ actionRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, 200);
+ actionRect.Translate(Vector3.left * 8);
+ actionGroup.SetActive(false);
+
+ buttonGroup = (actionGroup, groupButton);
+ _actionGroups.Add(group, buttonGroup);
+
+ // Make sure that the settings is the first button
+ if (group == SettingsButtonBase.Group)
+ {
+ groupButton.Transform.SetAsFirstSibling();
+ }
}
Color? normalColour = ColorUtility.TryParseHtmlString(colour, out var onlyColour) ? onlyColour : null;
- var button = UIFactory.CreateButton(buttonGroup, id, text, normalColour);
- UIFactory.SetLayoutElement(button.Component.gameObject, minHeight: 25, minWidth: 200, flexibleWidth: 0, flexibleHeight: 0);
+ var actionButton = UIFactory.CreateButton(buttonGroup.Item1, id, text, normalColour);
+ UIFactory.SetLayoutElement(actionButton.Component.gameObject, minHeight: 25, minWidth: 200, flexibleWidth: 0, flexibleHeight: 0);
+
+ return actionButton;
+ }
+
+ public void ToggleGroup(string group)
+ {
+ // Deactivate any active group
+ if (_activeGroup != "" && _actionGroups.TryGetValue(_activeGroup, out var previousActiveGroup))
+ {
+ previousActiveGroup.Item1.SetActive(false);
+ previousActiveGroup.Item2.ButtonText.text = ContractText;
+ previousActiveGroup.Item2.Component.colors = ClosedButtonColour;
+ }
+
+ // Only set the active group if we have a record of it
+ _activeGroup = _activeGroup == group || !_actionGroups.ContainsKey(group) ? "" : group;
+
+ // activate the new group as required
+ if (_activeGroup != "" && _actionGroups.TryGetValue(_activeGroup, out var newActiveGroup))
+ {
+ newActiveGroup.Item1.SetActive(true);
+ newActiveGroup.Item2.ButtonText.text = ExpandText;
+ newActiveGroup.Item2.Component.colors = OpenButtonColour;
+ }
+ }
+
+ public void ShowGroup(string group)
+ {
+ // Just ignore this if the group is already active, or blank, or we have no record of it
+ if (_activeGroup == group || group == "" || !_actionGroups.TryGetValue(group, out var newActiveGroup)) return;
+
+ _activeGroup = group;
+
+ // activate the new group as required
+ newActiveGroup.Item1.SetActive(true);
+ newActiveGroup.Item2.ButtonText.text = ExpandText;
+ newActiveGroup.Item2.Component.colors = OpenButtonColour;
+ }
+
+ public void HideGroup()
+ {
+ if (_activeGroup == "") return;
+
+ if (_actionGroups.TryGetValue(_activeGroup, out var oldActiveGroup))
+ {
+ oldActiveGroup.Item1.SetActive(false);
+ oldActiveGroup.Item2.ButtonText.text = ContractText;
+ oldActiveGroup.Item2.Component.colors = ClosedButtonColour;
+ }
- return button;
+ _activeGroup = "";
}
}
\ No newline at end of file
diff --git a/ClientUI/UI/Panel/ContentPanel.cs b/ClientUI/UI/Panel/ContentPanel.cs
index bedd6a3..2e06ea2 100644
--- a/ClientUI/UI/Panel/ContentPanel.cs
+++ b/ClientUI/UI/Panel/ContentPanel.cs
@@ -1,6 +1,4 @@
-using BepInEx.Logging;
using ClientUI.UniverseLib.UI.Panels;
-using TMPro;
using UnityEngine;
using UnityEngine.UI;
using XPShared.Transport.Messages;
@@ -23,10 +21,7 @@ public class ContentPanel : ResizeablePanelBase
public override PanelDragger.ResizeTypes CanResize =>
_canDragAndResize ? PanelDragger.ResizeTypes.Horizontal : PanelDragger.ResizeTypes.None;
- private const string ExpandText = "+";
- private const string ContractText = "\u2212"; // Using unicode instead of "-" as it centers better
private GameObject _uiAnchor;
- private ClientUI.UniverseLib.UI.Models.ButtonRef _expandButton;
private ActionPanel _actionPanel;
private ProgressBarPanel _progressBarPanel;
private NotificationPanel _notificationsPanel;
@@ -52,28 +47,15 @@ protected override void ConstructPanelContent()
Dragger.DraggableArea = Rect;
Dragger.OnEndResize();
- _expandButton = UIFactory.CreateButton(ContentRoot, "ExpandActionsButton", ExpandText);
- UIFactory.SetLayoutElement(_expandButton.GameObject, ignoreLayout: true);
- _expandButton.ButtonText.fontSize = 30;
- _expandButton.OnClick = ToggleActionPanel;
- _expandButton.Transform.anchorMin = Vector2.up;
- _expandButton.Transform.anchorMax = Vector2.up;
- _expandButton.Transform.pivot = Vector2.one;
- _expandButton.Transform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, 30);
- _expandButton.Transform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, 30);
- _expandButton.ButtonText.overflowMode = TextOverflowModes.Overflow;
- _expandButton.Transform.Translate(Vector3.left * 10);
- _expandButton.GameObject.SetActive(false);
-
- var actionContentHolder = UIFactory.CreateUIObject("ActionsContent", ContentRoot);
- UIFactory.SetLayoutGroup(actionContentHolder, false, false, true, true, 2, 2, 2, 2, 2, TextAnchor.UpperLeft);
+ var actionContentHolder = UIFactory.CreateUIObject("ActionGroupButtonContent", ContentRoot);
+ UIFactory.SetLayoutGroup(actionContentHolder, false, false, true, true);
UIFactory.SetLayoutElement(actionContentHolder, ignoreLayout: true);
- var actionRect = actionContentHolder.GetComponent();
- actionRect.anchorMin = Vector2.up;
- actionRect.anchorMax = Vector2.up;
- actionRect.pivot = Vector2.one;
- actionRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, 200);
- actionRect.Translate(Vector3.left * 10 + Vector3.down * 45);
+
+ // Set anchor/pivot to top left so panel can expand out left
+ var actionsRect = actionContentHolder.GetComponent();
+ actionsRect.SetAnchors(RectExtensions.PivotPresets.TopLeft);
+ actionsRect.SetPivot(RectExtensions.PivotPresets.TopLeft);
+ actionsRect.Translate(Vector3.left * 10);
_actionPanel = new ActionPanel(actionContentHolder);
_actionPanel.Active = false;
@@ -136,7 +118,7 @@ internal override void Reset()
internal void SetButton(ActionSerialisedMessage data, Action onClick = null)
{
- _expandButton.GameObject.SetActive(true);
+ _actionPanel.Active = true;
_actionPanel.SetButton(data, onClick);
}
@@ -152,15 +134,15 @@ internal void AddMessage(NotificationMessage data)
_notificationsPanel.AddNotification(data);
}
- internal void OpenActionPanel()
+ internal void OpenActionPanel(string group)
{
- if (!_actionPanel.Active) ToggleActionPanel();
+ _actionPanel.Active = true;
+ _actionPanel.ShowGroup(group);
}
- private void ToggleActionPanel()
+ internal void CloseActionPanel()
{
- _actionPanel.Active = !_actionPanel.Active;
- _expandButton.ButtonText.text = _actionPanel.Active ? ContractText : ExpandText;
+ _actionPanel.HideGroup();
}
private void ToggleDragging(bool active)
diff --git a/ClientUI/UI/Panel/ProgressBar.cs b/ClientUI/UI/Panel/ProgressBar.cs
index 67fd6c2..23cb5fe 100644
--- a/ClientUI/UI/Panel/ProgressBar.cs
+++ b/ClientUI/UI/Panel/ProgressBar.cs
@@ -28,6 +28,7 @@ public class ProgressBar
private readonly FrameTimer _timer = new();
private int _alertTimeRemainingMs = 0;
private bool _alertTransitionOff = true;
+ private bool _alertDestroyOnEnd = false;
private const int TaskIterationDelay = 15;
// Timeline:
@@ -155,7 +156,7 @@ public void SetProgress(float progress, string header, string tooltip, ActiveSta
}
}
- public void FadeOut()
+ public void FadeOut(bool destroyOnEnd = false)
{
if (_alertTimeRemainingMs > 0)
{
@@ -180,6 +181,8 @@ public void FadeOut()
_activeState = ActiveState.NotActive;
_contentBase.SetActive(false);
}
+
+ _alertDestroyOnEnd = destroyOnEnd;
}
// See constants section for timeline
@@ -232,6 +235,11 @@ private void AlertIteration()
_contentBase.SetActive(false);
OnProgressBarMinimised();
}
+
+ if (_alertDestroyOnEnd)
+ {
+ GameObject.Destroy(_contentBase);
+ }
break;
}
diff --git a/ClientUI/UI/Panel/ProgressBarPanel.cs b/ClientUI/UI/Panel/ProgressBarPanel.cs
index d090b5a..c5c11af 100644
--- a/ClientUI/UI/Panel/ProgressBarPanel.cs
+++ b/ClientUI/UI/Panel/ProgressBarPanel.cs
@@ -54,12 +54,16 @@ public void ChangeProgress(ProgressSerialisedMessage data)
{
if (!_bars.TryGetValue(data.Label, out var progressBar))
{
+ // Don't add a bar just to remove it
+ if (data.Active == ProgressSerialisedMessage.ActiveState.Remove) return;
progressBar = AddBar(data.Group, data.Label);
}
-
- var validatedProgress = Math.Clamp(data.ProgressPercentage, 0f, 1f);
+
+ var nullProgress = data.ProgressPercentage < 0;
+ var validatedProgress = nullProgress ? 1f : Math.Min(data.ProgressPercentage, 1f);
+ var tooltip = nullProgress ? data.Tooltip : $"{data.Tooltip} ({validatedProgress:P})";
var colour = Colour.ParseColour(data.Colour, validatedProgress);
- progressBar.SetProgress(validatedProgress, data.Header, $"{data.Tooltip} ({validatedProgress:P})", data.Active, colour, data.Change, data.Flash);
+ progressBar.SetProgress(validatedProgress, data.Header, tooltip, data.Active, colour, data.Change, data.Flash);
// Set all other labels to disappear if this is set to OnlyActive
if (data.Active == ProgressSerialisedMessage.ActiveState.OnlyActive)
@@ -73,6 +77,16 @@ public void ChangeProgress(ProgressSerialisedMessage data)
otherProgressBar.FadeOut();
}
});
+ } else if (data.Active == ProgressSerialisedMessage.ActiveState.Remove)
+ {
+ // Remove from group.BarLabels
+ var group = _groups[data.Group];
+ group.BarLabels.Remove(data.Label);
+ // Remove from _bars
+ _bars.Remove(data.Label);
+
+ // Remove the progress bar after the fadeout
+ progressBar.FadeOut(true);
}
// TODO work out how/when this should happen
diff --git a/ClientUI/UI/Panel/SettingsButtonBase.cs b/ClientUI/UI/Panel/SettingsButtonBase.cs
index 7320ff8..9d50460 100644
--- a/ClientUI/UI/Panel/SettingsButtonBase.cs
+++ b/ClientUI/UI/Panel/SettingsButtonBase.cs
@@ -5,7 +5,7 @@ namespace ClientUI.UI.Panel;
public abstract class SettingsButtonBase
{
- private const string Group = "UISettings";
+ internal const string Group = "UISettings";
private readonly string _id;
private readonly ConfigEntry _setting;
diff --git a/ClientUI/UI/Panel/TextPanel.cs b/ClientUI/UI/Panel/TextPanel.cs
index c43c679..c7a2b70 100644
--- a/ClientUI/UI/Panel/TextPanel.cs
+++ b/ClientUI/UI/Panel/TextPanel.cs
@@ -50,6 +50,7 @@ internal override void Reset()
{
SetTitle("");
_text.SetText("");
+ SetActive(false);
}
protected override void OnClosePanelClicked()
diff --git a/ClientUI/UI/RectExtensions.cs b/ClientUI/UI/RectExtensions.cs
index 4fb3e83..2db90a5 100644
--- a/ClientUI/UI/RectExtensions.cs
+++ b/ClientUI/UI/RectExtensions.cs
@@ -69,4 +69,48 @@ internal static void SetPivot(this RectTransform rect, Vector2 pivot)
rect.pivot = pivot;
rect.localPosition -= deltaPosition;
}
+
+ public enum PivotPresets
+ {
+ TopLeft,
+ TopCenter,
+ TopRight,
+
+ MiddleLeft,
+ MiddleCenter,
+ MiddleRight,
+
+ BottomLeft,
+ BottomCenter,
+ BottomRight,
+ }
+
+ internal static void SetPivot(this RectTransform rect, PivotPresets preset)
+ {
+ rect.SetPivot(GetVector2FromPivot(preset));
+ }
+
+ internal static void SetAnchors(this RectTransform rect, PivotPresets preset)
+ {
+ var anchor = GetVector2FromPivot(preset);
+ rect.anchorMin = anchor;
+ rect.anchorMax = anchor;
+ }
+
+ internal static Vector2 GetVector2FromPivot(PivotPresets preset)
+ {
+ return preset switch
+ {
+ (PivotPresets.TopLeft) => new Vector2(0, 1),
+ (PivotPresets.TopCenter) => new Vector2(0.5f, 1),
+ (PivotPresets.TopRight) => new Vector2(1, 1),
+ (PivotPresets.MiddleLeft) => new Vector2(0, 0.5f),
+ (PivotPresets.MiddleCenter) => new Vector2(0.5f, 0.5f),
+ (PivotPresets.MiddleRight) => new Vector2(1, 0.5f),
+ (PivotPresets.BottomLeft) => new Vector2(0, 0),
+ (PivotPresets.BottomCenter) => new Vector2(0.5f, 0),
+ (PivotPresets.BottomRight) => new Vector2(1, 0),
+ _ => default
+ };
+ }
}
\ No newline at end of file
diff --git a/ClientUI/UI/UIManager.cs b/ClientUI/UI/UIManager.cs
index a7340bf..a2788d1 100644
--- a/ClientUI/UI/UIManager.cs
+++ b/ClientUI/UI/UIManager.cs
@@ -45,6 +45,12 @@ public static void SetActive(bool active)
ContentPanel.SetActive(active);
+ // Hide any open menus
+ if (!active) ContentPanel.CloseActionPanel();
+
+ // Hide the panel, but don't make it reappear
+ if (!active) TextPanel.SetActive(false);
+
IsInitialised = true;
}
diff --git a/ClientUI/UniverseLib/UI/UIFactory.cs b/ClientUI/UniverseLib/UI/UIFactory.cs
index 5a0ffc8..211571f 100644
--- a/ClientUI/UniverseLib/UI/UIFactory.cs
+++ b/ClientUI/UniverseLib/UI/UIFactory.cs
@@ -51,21 +51,28 @@ internal static void SetDefaultTextValues(TextMeshProUGUI text)
text.fontSize = 14;
}
+ internal static ColorBlock CreateColourBlock(Color baseColour)
+ {
+ // Basing the complementary colours using HSV will generally get a better arrangement.
+ Color.RGBToHSV(baseColour, out var h, out var s, out var v);
+ return new ColorBlock()
+ {
+ normalColor = baseColour,
+ highlightedColor = Color.HSVToRGB(h, s, v * 1.2f),
+ selectedColor = Color.HSVToRGB(h, s, v * 1.1f),
+ pressedColor = Color.HSVToRGB(h, s, v * 0.7f),
+ disabledColor = Color.HSVToRGB(h, s, v * 0.4f),
+ colorMultiplier = 1
+ };
+ }
+
internal static void SetDefaultSelectableValues(Selectable selectable)
{
Navigation nav = selectable.navigation;
nav.mode = Navigation.Mode.Explicit;
selectable.navigation = nav;
-
- var colourBlock = new ColorBlock()
- {
- normalColor = new Color(0.2f, 0.2f, 0.2f),
- highlightedColor = new Color(0.3f, 0.3f, 0.3f),
- pressedColor = new Color(0.15f, 0.15f, 0.15f),
- colorMultiplier = 1
- };
- selectable.colors = colourBlock;
+ selectable.colors = CreateColourBlock(new Color(0.2f, 0.2f, 0.2f));
}
@@ -296,18 +303,9 @@ public static TextMeshProUGUI CreateLabel(GameObject parent, string name, string
public static ButtonRef CreateButton(GameObject parent, string name, string text, Color? normalColor = null)
{
var baseColour = normalColor ?? Colour.SliderFill;
- var colourBlock = new ColorBlock()
- {
- normalColor = baseColour,
- highlightedColor = baseColour * 1.2f,
- selectedColor = baseColour * 1.1f,
- pressedColor = baseColour * 0.7f,
- disabledColor = baseColour * 0.4f,
- colorMultiplier = 1
- };
var buttonRef = CreateButton(parent, name, text, default(ColorBlock));
- buttonRef.Component.colors = colourBlock;
+ buttonRef.Component.colors = CreateColourBlock(baseColour);
return buttonRef;
}
@@ -423,14 +421,7 @@ public static GameObject CreateSlider(GameObject parent, string name, out Slider
slider.targetGraphic = handleImage;
slider.direction = Slider.Direction.LeftToRight;
- var colourBlock = new ColorBlock()
- {
- normalColor = new Color(0.4f, 0.4f, 0.4f),
- highlightedColor = new Color(0.55f, 0.55f, 0.55f),
- pressedColor = new Color(0.3f, 0.3f, 0.3f),
- colorMultiplier = 1
- };
- slider.colors = colourBlock;
+ slider.colors = CreateColourBlock(new Color(0.4f, 0.4f, 0.4f));
return sliderObj;
}
@@ -555,14 +546,7 @@ public static InputFieldRef CreateInputField(GameObject parent, string name, str
inputField.transition = Selectable.Transition.ColorTint;
inputField.targetGraphic = mainImage;
- var colourBlock = new ColorBlock()
- {
- normalColor = new Color(1, 1, 1, 1),
- highlightedColor = new Color(0.95f, 0.95f, 0.95f, 1.0f),
- pressedColor = new Color(0.78f, 0.78f, 0.78f, 1.0f),
- colorMultiplier = 1
- };
- inputField.colors = colourBlock;
+ inputField.colors = CreateColourBlock(Color.white);
GameObject textArea = CreateUIObject("TextArea", mainObj);
textArea.AddComponent();
@@ -640,14 +624,7 @@ public static GameObject CreateDropdown(GameObject parent, string name, out TMP_
GameObject scrollbarObj = CreateScrollbar(templateObj, "DropdownScroll", out Scrollbar scrollbar);
scrollbar.SetDirection(Scrollbar.Direction.BottomToTop, true);
- var scrollbarColours = new ColorBlock()
- {
- normalColor = new Color(0.45f, 0.45f, 0.45f),
- highlightedColor = new Color(0.6f, 0.6f, 0.6f),
- pressedColor = new Color(0.4f, 0.4f, 0.4f),
- colorMultiplier = 1
- };
- scrollbar.colors = scrollbarColours;
+ scrollbar.colors = CreateColourBlock(new Color(0.45f, 0.45f, 0.45f));
RectTransform scrollRectTransform = scrollbarObj.GetComponent();
@@ -905,15 +882,8 @@ public static GameObject CreateSliderScrollbar(GameObject parent, out Slider sli
slider.direction = Slider.Direction.TopToBottom;
SetLayoutElement(mainObj, minWidth: 25, flexibleWidth: 0, flexibleHeight: 9999);
-
- slider.colors = new ColorBlock()
- {
- normalColor = new Color(0.4f, 0.4f, 0.4f),
- highlightedColor = new Color(0.5f, 0.5f, 0.5f),
- pressedColor = new Color(0.3f, 0.3f, 0.3f),
- disabledColor = new Color(0.5f, 0.5f, 0.5f),
- colorMultiplier = 1
- };
+
+ slider.colors = CreateColourBlock(new Color(0.4f, 0.4f, 0.4f));
return mainObj;
}
@@ -936,7 +906,7 @@ public static GameObject CreateScrollView(GameObject parent, string name, out Ga
mainRect.anchorMax = Vector2.one;
Image mainImage = mainObj.AddComponent();
mainImage.type = Image.Type.Filled;
- mainImage.color = (color == default) ? Colour.Level1 : color;
+ mainImage.color = (color == default) ? Colour.DarkBackground : color;
SetLayoutElement(mainObj, flexibleHeight: 9999, flexibleWidth: 9999);
diff --git a/Command.md b/Command.md
index 4400c16..a87fa2c 100644
--- a/Command.md
+++ b/Command.md
@@ -1,42 +1,55 @@
To regenerate this table, uncomment the `GenerateCommandMd` function in `Plugin.ValidateCommandPermissions`. Then check the LogOutput.log in the server after starting.
Usage arguments: <> are required, [] are optional
-| Command | Short hand | Usage | Description | Admin | Level |
-|---------------------------|------------|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------|
-| `.db load` | | `[loadBackup]` | Force the plugin to load XPRising DB from file. Use loadBackup to load from the backup directory instead of the main directory. | ☐ | `100` |
-| `.db save` | | `[saveBackup]` | Force the plugin to write XPRising DB to file. Use saveBackup to additionally save to the backup directory. | ☐ | `100` |
-| `.db wipe` | | | Force the plugin to wipe and re-initialise the database. | ☐ | `100` |
-| `.experience get` | `.xp g` | | Display your current xp | ☐ | `0` |
-| `.experience log` | `.xp l` | | Toggles logging of xp gain. | ☐ | `0` |
-| `.experience questSkip` | `.xp qs` | | Skips the level requirement quest. Quest should be auto-skipped, but just in case you need it. | ☐ | `0` |
-| `.experience set` | `.xp s` | ` ` | Sets the specified player's level to the start of the given level | ☐ | `100` |
-| `.group add` | `.ga` | `[playerName]` | Adds a player to your group. Leave blank to add all "close" players to your group. | ☐ | `0` |
-| `.group ignore` | `.gi` | | Toggles ignoring group invites for yourself. | ☐ | `0` |
-| `.group leave` | `.gl` | | Leave your current group. | ☐ | `0` |
-| `.group no` | `.gn` | `[index]` | Reject the oldest invite, or the invite specified by the provided index. | ☐ | `0` |
-| `.group show` | `.gs` | | Prints out info about your current group and your group preferences | ☐ | `0` |
-| `.group wipe` | `.gw` | | Clear out any existing groups and invites | ☐ | `100` |
-| `.group yes` | `.gy` | `[index]` | Accept the oldest invite, or the invite specified by the provided index. | ☐ | `0` |
-| `.l10n` | | | List available localisations | ☐ | `0` |
-| `.l10n set` | `.l10n s` | `` | Set your localisation language | ☐ | `0` |
-| `.mastery add` | `.m a` | ` ` | Adds the amount to the mastery of the specified type | ☐ | `100` |
-| `.mastery get` | `.m g` | `[masteryType]` | Display your current mastery progression for your active or specified mastery type | ☐ | `0` |
-| `.mastery get-all` | `.m ga` | | Displays your current mastery progression in for all types that have progression (zero progression masteries are not shown). | ☐ | `0` |
-| `.mastery log` | `.m l` | | Toggles logging of mastery gain. | ☐ | `0` |
-| `.mastery reset` | `.m r` | `` | Resets mastery to gain more power with it. | ☐ | `0` |
-| `.mastery reset-all` | `.m ra` | `[category]` | Resets all mastery to gain more power. Category can be used to reset all weapons vs all bloodlines. | ☐ | `0` |
-| `.mastery set` | `.m s` | ` ` | Sets the specified player's mastery to a specific value | ☐ | `100` |
-| `.permission add admin` | `.paa` | | Gives the current user the max privilege level. Requires user to be admin. | ☑ | `100` |
-| `.permission command` | `.p c` | | Display current privilege levels for commands. | ☐ | `100` |
-| `.permission set command` | `.psc` | ` <0-100>` | Sets the required privilege level for a command. | ☐ | `100` |
-| `.permission set user` | `.psu` | ` <0-100>` | Sets the privilege level for a user. | ☐ | `100` |
-| `.permission user` | `.p u` | | Display current privilege levels for users. | ☐ | `100` |
-| `.playerbuffs` | `.pb` | | Display the player's buff details. | ☐ | `0` |
-| `.playerinfo` | `.pi` | | Display the player's information details. | ☐ | `0` |
-| `.playerinfo` | `.pi` | `` | Display the requested player's information details. | ☐ | `100` |
-| `.wanted fixminions` | `.w fm` | | Remove broken gloomrot technician units | ☐ | `100` |
-| `.wanted get` | `.w g` | | Shows your current wanted level | ☐ | `0` |
-| `.wanted log` | `.w l` | | Toggle logging of heat data. | ☐ | `0` |
-| `.wanted set` | `.w s` | ` ` | Sets the current wanted level | ☐ | `100` |
-| `.wanted trigger` | `.w t` | `[name]` | Triggers the ambush check for yourself or the given user | ☐ | `100` |
-| `.xpconf` | `.xpc` | `[setting [value]]` | Display or set the player's current config. Use [`logging`, `groupIgnore`, `text`, `colours`] or [`l`, `gi`, `t`]. Text size requires one of [`tiny`, `small`, `normal`]. The `colours` setting can be used to set the colours for the progress bars in the UI. This accepts a list of HTML colour codes (e.g. "red,#00ffaa,#ccc") | ☐ | `0` |
\ No newline at end of file
+| Command | Short hand | Usage | Description | Admin | Level |
+|---------------------------|------------|----------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|-------|-------|
+| `.challenge leaderboard` | `.clb` | `` | Shows the leaderboard for the challenge at the specified index | ☐ | `0` |
+| `.challenge list` | `.cl` | | Lists available challenges and their progress | ☐ | `0` |
+| `.challenge log` | `.clog` | | Toggles logging of challenges. | ☐ | `0` |
+| `.challenge toggle` | `.ct` | `` | Accepts or resets the challenge at the specified index | ☐ | `0` |
+| `.db load` | | `[loadBackup]` | Force the plugin to load XPRising DB from file. Use loadBackup to load from the backup directory instead of the main directory. | ☐ | `100` |
+| `.db save` | | `[saveBackup]` | Force the plugin to write XPRising DB to file. Use saveBackup to additionally save to the backup directory. | ☐ | `100` |
+| `.db wipe` | | | Force the plugin to wipe and re-initialise the database. | ☐ | `100` |
+| `.experience get` | `.xp g` | | Display your current xp | ☐ | `0` |
+| `.experience log` | `.xp l` | | Toggles logging of xp gain. | ☐ | `0` |
+| `.experience questSkip` | `.xp qs` | | Skips the level requirement quest. Quest should be auto-skipped, but just in case you need it. | ☐ | `0` |
+| `.experience set` | `.xp s` | ` ` | Sets the specified player's level to the start of the given level | ☐ | `100` |
+| `.group add` | `.ga` | `[playerName]` | Adds a player to your group. Leave blank to add all "close" players to your group. | ☐ | `0` |
+| `.group ignore` | `.gi` | | Toggles ignoring group invites for yourself. | ☐ | `0` |
+| `.group leave` | `.gl` | | Leave your current group. | ☐ | `0` |
+| `.group no` | `.gn` | `[index]` | Reject the oldest invite, or the invite specified by the provided index. | ☐ | `0` |
+| `.group show` | `.gs` | | Prints out info about your current group and your group preferences | ☐ | `0` |
+| `.group wipe` | `.gw` | | Clear out any existing groups and invites | ☐ | `100` |
+| `.group yes` | `.gy` | `[index]` | Accept the oldest invite, or the invite specified by the provided index. | ☐ | `0` |
+| `.l10n` | | | List available localisations | ☐ | `0` |
+| `.l10n set` | `.l10n s` | `` | Set your localisation language | ☐ | `0` |
+| `.mastery add` | `.m a` | ` ` | Adds the amount to the mastery of the specified type | ☐ | `100` |
+| `.mastery get` | `.m g` | `[masteryType]` | Display your current mastery progression for your active or specified mastery type | ☐ | `0` |
+| `.mastery get-all` | `.m ga` | | Displays your current mastery progression in for all types that have progression (zero progression masteries are not shown). | ☐ | `0` |
+| `.mastery log` | `.m l` | | Toggles logging of mastery gain. | ☐ | `0` |
+| `.mastery reset` | `.m r` | `` | Resets mastery to gain more power with it. | ☐ | `0` |
+| `.mastery reset-all` | `.m ra` | `[category]` | Resets all mastery to gain more power. Category can be used to reset all weapons vs all bloodlines. | ☐ | `0` |
+| `.mastery set` | `.m s` | ` ` | Sets the specified player's mastery to a specific value | ☐ | `100` |
+| `.permission add admin` | `.paa` | | Gives the current user the max privilege level. Requires user to be admin. | ☑ | `100` |
+| `.permission command` | `.p c` | | Display current privilege levels for commands. | ☐ | `100` |
+| `.permission set command` | `.psc` | ` <0-100>` | Sets the required privilege level for a command. | ☐ | `100` |
+| `.permission set user` | `.psu` | ` <0-100>` | Sets the privilege level for a user. | ☐ | `100` |
+| `.permission user` | `.p u` | | Display current privilege levels for users. | ☐ | `100` |
+| `.playerbuffs` | `.pb` | | Display the player's buff details. | ☐ | `0` |
+| `.playerinfo` | `.pi` | | Display the player's information details. | ☐ | `0` |
+| `.playerinfo` | `.pi` | `` | Display the requested player's information details. | ☐ | `100` |
+| `.wanted fixminions` | `.w fm` | | Remove broken gloomrot technician units | ☐ | `100` |
+| `.wanted get` | `.w g` | | Shows your current wanted level | ☐ | `0` |
+| `.wanted log` | `.w l` | | Toggle logging of heat data. | ☐ | `0` |
+| `.wanted set` | `.w s` | ` ` | Sets the current wanted level | ☐ | `100` |
+| `.wanted trigger` | `.w t` | `[name]` | Triggers the ambush check for yourself or the given user | ☐ | `100` |
+| `.xpconf` | `.xpc` | `[setting [value]]` | Display or set the player's current config. See table below for details. | ☐ | `0` |
+
+#### `xpconf` options
+| Setting | Usage | Description |
+|-------------------------|-------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| logging (or l) | .xpconf l | Toggles chat logging for all loggable systems |
+| groupIgnore (or gi) | .xpconf gi | Toggles whether group invites are ignored |
+| barColours (or colours) | .xpconf colours "#fff,green,red,green,red,gray" | Specify a set of 6 colours to be used for the progress bars in the ClientUI. Order of colours: `[XP, Mastery, Blood, Challenge active, Challenge failed, Challenge inactive]`. This supports html colours (e.g. `#fff` or `#ffffff` or `white` for white) |
+| text (or t) | .xpconf t | Changes the size of the text send back in chat logs. `` should be one of: tiny, small, normal |
+| sct | .xpconf sct | Toggles scrolling combat text (sct) added by this mod (shows/hides the +X experience text) |
\ No newline at end of file
diff --git a/Documentation.md b/Documentation.md
index b71d082..c8b8f1a 100644
--- a/Documentation.md
+++ b/Documentation.md
@@ -86,6 +86,98 @@ Note:
```
+## Challenge System
+
+This is a system that can be used to set up challenges for players to compete for rankings on a server leaderboard.
+Challenges are very customisable, giving options for setting up multi-stage challenges with each stage potentially having multiple objectives.
+
+Challenges are found in the `challenges.json` file. An example is shown below:
+
+```json
+{
+ "challenges": [
+ {
+ // ID is used to enable stat tracking against the same challenge
+ "id": "ed348084-85b7-4b44-926d-e9363464af84",
+ // Label shown to players for this challenge
+ "label": "Farbane menace",
+ // List of stages and objectives in each stage
+ "objectives": [
+ // Stage 1:
+ [
+ // requires player to kill 10 bandits in this stage
+ {
+ "killCount": 10,
+ "factions": [
+ "bandits"
+ ]
+ },
+ // requires player to kill 10 undead in this stage
+ {
+ "killCount": 10,
+ "factions": [
+ "undead"
+ ]
+ }
+ ],
+ // Stage 2:
+ [
+ // requires player to kill 1 VBlood
+ {
+ "killCount": 1,
+ "unitBloodType": [
+ "vBlood"
+ ]
+ }
+ ]
+ ],
+ // Can be used to make a challenge not repeatable
+ "canRepeat": true
+ },
+ {
+ "id": "5dc79545-3956-45c2-a2a8-0f4352da7830",
+ "label": "Kill bandits in 10m",
+ "objectives": [
+ [
+ {
+ "killCount": -1,
+ "factions": [
+ "bandits",
+ "wolves"
+ ],
+ "limit": "-00:10:00"
+ }
+ ]
+ ],
+ "canRepeat": true
+ }
+ ]
+}
+```
+
+#### Supported objective configuration:
+```json
+{
+ // Required number of kills for this objective to be completed
+ // Set to > 0 to make this a requirement
+ "killCount": 0,
+ // List of factions accepted for counting as kills (supported factions are: bandits, blackfangs, critters, gloomrot, legion, militia, undead, werewolf)
+ // Leave this empty to allow any faction
+ "factions": [],
+ // List of blood types accepted for counting as kills
+ // Leave this empty to allow any blood type
+ "unitBloodType": [],
+ // Required time limit
+ // - (positive) requires kills to be completed in time (e.g. must make 10 kills in 1 min)
+ // - (negative) records score generated from kills/damage within time limit (e.g. how many kills can you make in 10 mins?)
+ // Format: "hh:mm:ss" (e.g. "00:01:00" or "-00:10:00")
+ // Don't include this to ignore any time limits
+ "limit": "00:00:00"
+}
+```
+
+
+
## Clans and Groups and XP sharing
Killing with other vampires can share XP and wanted heat levels within the group.
diff --git a/README.md b/README.md
index 96be60e..451540d 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,9 @@ There is an optional (but recommended) UI mod that supports displaying XP bars a
### Wanted system
- A system that tracks player kills against different factions in the game and causes factions to ambush players with enemies as their "heat" level increases.
+### Challenge system
+- A system to set up challenges for players and records scores that are used on a leaderboard to rank players
+
## XPRising Requirements
- [BepInExPack V Rising](https://thunderstore.io/c/v-rising/p/BepInEx/BepInExPack_V_Rising/) (Server/Client)
diff --git a/XPRising/Commands/ChallengeCommands.cs b/XPRising/Commands/ChallengeCommands.cs
new file mode 100644
index 0000000..b00b468
--- /dev/null
+++ b/XPRising/Commands/ChallengeCommands.cs
@@ -0,0 +1,74 @@
+using VampireCommandFramework;
+using XPRising.Systems;
+using XPRising.Utils;
+
+namespace XPRising.Commands
+{
+ public static class ChallengeCommands {
+ private static void CheckChallengeSystemActive(ChatCommandContext ctx)
+ {
+ if (!Plugin.ChallengeSystemActive)
+ {
+ var message = L10N.Get(L10N.TemplateKey.SystemNotEnabled)
+ .AddField("{system}", "Challenge");
+ throw Output.ChatError(ctx, message);
+ }
+ }
+
+ [Command("challenge list", shortHand: "cl", usage: "", description: "Lists available challenges and their progress", adminOnly: false)]
+ public static void ChallengeListCommand(ChatCommandContext ctx)
+ {
+ CheckChallengeSystemActive(ctx);
+ var challenges = ChallengeSystem.ListChallenges(ctx.User.PlatformId);
+ var output = challenges.Select(challenge =>
+ new L10N.LocalisableString($"{challenge.challenge.Label}: {challenge.status}"));
+ Output.ChatReply(ctx, L10N.Get(L10N.TemplateKey.ChallengeListHeader), output.ToArray());
+ }
+
+ [Command("challenge toggle", shortHand: "ct", usage: "", description: "Accepts or resets the challenge at the specified index", adminOnly: false)]
+ public static void ChallengeToggleCommand(ChatCommandContext ctx, int challengeIndex)
+ {
+ CheckChallengeSystemActive(ctx);
+ ChallengeSystem.ToggleChallenge(ctx.User.PlatformId, challengeIndex);
+ }
+
+ [Command("challenge leaderboard", shortHand: "clb", usage: "", description: "Shows the leaderboard for the challenge at the specified index", adminOnly: false)]
+ public static void ChallengeStats(ChatCommandContext ctx, int challengeIndex)
+ {
+ CheckChallengeSystemActive(ctx);
+ var stats = ChallengeSystem.ListChallengeStats(challengeIndex, 5, out var challenge);
+ var output = stats.Count == 0 ?
+ [L10N.Get(L10N.TemplateKey.ChallengeLeaderboardEmpty)] :
+ stats.Select((stat, index) =>
+ {
+ var scoreStrings = new List();
+ if (stat.Item2.FastestTime > TimeSpan.Zero) scoreStrings.Add($"{FormatTimeSpan(stat.Item2.FastestTime),14}");
+ if (stat.Item2.Score > 0) scoreStrings.Add($"{stat.Item2.Score:D6}");
+ scoreStrings.Add(PlayerCache.GetNameFromSteamID(stat.Item1));
+
+ return new L10N.LocalisableString($"{index + 1,3:D}: {string.Join(" - ", scoreStrings)}");
+ }).ToArray();
+ Output.ChatReply(ctx, L10N.Get(L10N.TemplateKey.ChallengeLeaderboard).AddField("{challenge}", challenge.Label), output);
+ }
+
+ [Command("challenge log", "clog", "", "Toggles logging of challenges.", adminOnly: false)]
+ public static void LogChallenges(ChatCommandContext ctx)
+ {
+ CheckChallengeSystemActive(ctx);
+
+ var steamID = ctx.User.PlatformId;
+ var loggingData = Database.PlayerPreferences[steamID];
+ loggingData.LoggingChallenges = !loggingData.LoggingChallenges;
+ var message = loggingData.LoggingChallenges
+ ? L10N.Get(L10N.TemplateKey.SystemLogEnabled)
+ : L10N.Get(L10N.TemplateKey.SystemLogDisabled);
+ Output.ChatReply(ctx, message.AddField("{system}", "Challenge"));
+ Database.PlayerPreferences[steamID] = loggingData;
+ }
+
+ private static string FormatTimeSpan(TimeSpan ts)
+ {
+ return ts.TotalHours >= 1 ? $@"{ts.TotalHours:F0}h {ts:mm\m\ ss\.ff\s}" : $@"{ts:mm\m\ ss\.ff\s}";
+ }
+ }
+}
diff --git a/XPRising/Commands/PlayerInfoCommands.cs b/XPRising/Commands/PlayerInfoCommands.cs
index 9998a27..803777d 100644
--- a/XPRising/Commands/PlayerInfoCommands.cs
+++ b/XPRising/Commands/PlayerInfoCommands.cs
@@ -43,6 +43,7 @@ public static void PlayerConfigCommand(ChatCommandContext ctx, string setting =
preferences.LoggingExp = newValue;
preferences.LoggingMastery = newValue;
preferences.LoggingWanted = newValue;
+ preferences.LoggingChallenges = newValue;
break;
case "groupIgnore":
case "group":
@@ -53,6 +54,9 @@ public static void PlayerConfigCommand(ChatCommandContext ctx, string setting =
case "t":
preferences.TextSize = PlayerPreferences.ConvertTextToSize(value);
break;
+ case "sct":
+ preferences.ScrollingCombatText = !preferences.ScrollingCombatText;
+ break;
case "barColours":
case "colours":
var colours = value is "" or "default" ? [] : value.Split(",");
@@ -77,13 +81,14 @@ public static void PlayerConfigCommand(ChatCommandContext ctx, string setting =
messages.Add(LoggingMessage(preferences.LoggingExp, "XP"));
messages.Add(LoggingMessage(preferences.LoggingMastery, "Mastery system"));
messages.Add(LoggingMessage(preferences.LoggingWanted, "Wanted heat"));
+ messages.Add(LoggingMessage(preferences.LoggingChallenges, "Challenge"));
messages.Add(L10N.Get(preferences.IgnoringInvites ? L10N.TemplateKey.AllianceGroupIgnore : L10N.TemplateKey.AllianceGroupListen));
messages.Add(L10N.Get(L10N.TemplateKey.PreferenceTextSize).AddField("{textSize}", PlayerPreferences.ConvertSizeToText(preferences.TextSize)));
messages.Add(L10N.Get(L10N.TemplateKey.PreferenceBarColours).AddField("{colours}", string.Join(", ", preferences.BarColoursWithDefaults.Select(colour => $"{colour}"))));
Output.ChatReply(ctx, L10N.Get(L10N.TemplateKey.PreferenceTitle), messages.ToArray());
// Update the UI as well
- ClientActionHandler.SendUIData(ctx.User, true, true);
+ ClientActionHandler.SendUIData(ctx.User, true, true, preferences);
}
[Command(name: "playerinfo", shortHand: "pi", adminOnly: false, usage: "", description: "Display the player's information details.")]
diff --git a/XPRising/Commands/WantedCommands.cs b/XPRising/Commands/WantedCommands.cs
index 1ac2a23..0b449fb 100644
--- a/XPRising/Commands/WantedCommands.cs
+++ b/XPRising/Commands/WantedCommands.cs
@@ -27,7 +27,7 @@ private static void CheckWantedSystemActive(ChatCommandContext ctx)
}
}
- private static void SendFactionWantedMessage(PlayerHeatData heatData, Entity userEntity, bool userIsAdmin) {
+ private static void SendFactionWantedMessage(PlayerHeatData heatData, Entity userEntity, bool userIsAdmin, ulong steamId) {
bool isWanted = false;
foreach (Faction faction in FactionHeat.ActiveFactions) {
if (heatData.heat.TryGetValue(faction, out var heat))
@@ -37,17 +37,17 @@ private static void SendFactionWantedMessage(PlayerHeatData heatData, Entity use
isWanted = true;
var wantedLevel = FactionHeat.GetWantedLevel(heat.level);
- Output.SendMessage(userEntity, new L10N.LocalisableString(FactionHeat.GetFactionStatus(faction, heat.level)), $"#{FactionHeat.ColourGradient[wantedLevel - 1]}");
+ Output.SendMessage(userEntity, new L10N.LocalisableString(FactionHeat.GetFactionStatus(faction, heat.level, steamId)), $"#{FactionHeat.ColourGradient[wantedLevel - 1]}");
if (userIsAdmin && DebugLoggingConfig.IsLogging(LogSystem.Wanted))
{
var sinceAmbush = DateTime.Now - heat.lastAmbushed;
- var nextAmbush = Math.Max((int)(WantedSystem.ambush_interval - sinceAmbush.TotalSeconds), 0);
+ var nextAmbush = Math.Max((int)(WantedSystem.AmbushInterval - sinceAmbush.TotalSeconds), 0);
Output.DebugMessage(
userEntity,
$"Level: {heat.level:D} " +
$"Possible ambush in {nextAmbush:D}s " +
- $"Chance: {WantedSystem.ambush_chance:D}%");
+ $"Chance: {WantedSystem.AmbushChance:D}%");
}
}
}
@@ -64,7 +64,7 @@ public static void GetWanted(ChatCommandContext ctx)
var userEntity = ctx.Event.SenderUserEntity;
var heatData = WantedSystem.GetPlayerHeat(userEntity);
- SendFactionWantedMessage(heatData, userEntity, ctx.IsAdmin);
+ SendFactionWantedMessage(heatData, userEntity, ctx.IsAdmin, ctx.User.PlatformId);
}
[Command("set","s", " ", "Sets the current wanted level", adminOnly: false)]
@@ -96,7 +96,7 @@ public static void SetWanted(ChatCommandContext ctx, string name, string faction
targetUserEntity,
heatFaction,
heatLevel,
- DateTime.Now - TimeSpan.FromSeconds(WantedSystem.ambush_interval + 1));
+ DateTime.Now - TimeSpan.FromSeconds(WantedSystem.AmbushInterval + 1));
Output.ChatReply(ctx, L10N.Get(L10N.TemplateKey.WantedLevelSet).AddField("{playerName}", name));
}
diff --git a/XPRising/Configuration/WantedConfig.cs b/XPRising/Configuration/WantedConfig.cs
index 6f7937e..05dcc6c 100644
--- a/XPRising/Configuration/WantedConfig.cs
+++ b/XPRising/Configuration/WantedConfig.cs
@@ -16,12 +16,12 @@ public static void Initialize()
_configFile = new ConfigFile(configPath, true);
// Currently, we are never updating and saving the config file in game, so just load the values.
- WantedSystem.heat_cooldown = _configFile.Bind("Wanted", "Heat Cooldown", 10, "Set the reduction value for player heat per minute.").Value;
- WantedSystem.ambush_interval = _configFile.Bind("Wanted", "Ambush Interval", 60, "Set how many seconds player can be ambushed again since last ambush.").Value;
- WantedSystem.ambush_chance = _configFile.Bind("Wanted", "Ambush Chance", 50, "Set the percentage that an ambush may occur for every cooldown interval.").Value;
+ WantedSystem.HeatCooldown = _configFile.Bind("Wanted", "Heat Cooldown", 10, "Set the reduction value for player heat per minute.").Value;
+ WantedSystem.AmbushInterval = _configFile.Bind("Wanted", "Ambush Interval", 60, "Set how many seconds player can be ambushed again since last ambush.").Value;
+ WantedSystem.AmbushChance = _configFile.Bind("Wanted", "Ambush Chance", 50, "Set the percentage that an ambush may occur for every cooldown interval.").Value;
var ambushTimer = _configFile.Bind("Wanted", "Ambush Despawn Timer", 300f, "Despawn the ambush squad after this many second if they are still alive.\n" +
"Must be higher than 1.").Value;
- WantedSystem.vBloodMultiplier = _configFile.Bind("Wanted", "VBlood Heat Multiplier", 20, "Multiply the heat generated by VBlood kills.").Value;
+ WantedSystem.VBloodMultiplier = _configFile.Bind("Wanted", "VBlood Heat Multiplier", 20, "Multiply the heat generated by VBlood kills.").Value;
WantedSystem.RequiredDistanceFromVBlood = _configFile.Bind("Wanted", "Required distance from VBlood", 100f, "Set the distance required for players to be from a VBlood for an ambush to occur.\n" +
"This is to prevent cheesing bosses by spawning an ambush").Value;
var heatLostPercentage = _configFile.Bind("Wanted", "Heat percentage lost on death", 100, "The percentage of heat that a player will lose when they die.\n" +
@@ -30,6 +30,6 @@ public static void Initialize()
WantedSystem.HeatPercentageLostOnDeath = Math.Clamp(heatLostPercentage, 0, 100);
if (ambushTimer < 1) ambushTimer = 300f;
- WantedSystem.ambush_despawn_timer = ambushTimer;
+ WantedSystem.AmbushDespawnTimer = ambushTimer;
}
}
\ No newline at end of file
diff --git a/XPRising/Hooks/DeathHook.cs b/XPRising/Hooks/DeathHook.cs
index 86788d3..9182769 100644
--- a/XPRising/Hooks/DeathHook.cs
+++ b/XPRising/Hooks/DeathHook.cs
@@ -68,7 +68,7 @@ public static void Postfix(DeathEventListenerSystem __instance)
if (Plugin.ExperienceSystemActive || Plugin.WantedSystemActive || Plugin.BloodlineSystemActive || Plugin.WeaponMasterySystemActive)
{
- var isVBlood = Plugin.Server.EntityManager.TryGetComponentData(ev.Died, out BloodConsumeSource victimBlood) && Helper.IsVBlood(victimBlood);
+ var (_, _, isVBlood) = Helper.GetBloodInfo(ev.Died);
var useGroup = ExperienceSystem.GroupMaxDistance > 0;
diff --git a/XPRising/Models/Challenges/ChallengeState.cs b/XPRising/Models/Challenges/ChallengeState.cs
new file mode 100644
index 0000000..cc5d315
--- /dev/null
+++ b/XPRising/Models/Challenges/ChallengeState.cs
@@ -0,0 +1,98 @@
+using BepInEx.Logging;
+using XPRising.Models.ObjectiveTrackers;
+
+namespace XPRising.Models.Challenges;
+
+public class ChallengeState
+{
+ public string ChallengeId;
+ public List Stages;
+ public int ActiveStage { get; private set; }
+ public DateTime StartTime = DateTime.Now;
+
+ public State CurrentState()
+ {
+ if (ActiveStage >= Stages.Count) return State.ChallengeComplete;
+ return Stages[ActiveStage].CurrentState();
+ }
+
+ public bool CalculateScore(out TimeSpan timeTaken, out int score)
+ {
+ timeTaken = TimeSpan.Zero;
+ score = 0;
+ var scoreValid = true;
+
+ foreach (var stage in Stages)
+ {
+ if (stage.CalculateScore(out var stageTime, out var stageScore))
+ {
+ score += (int)stageScore;
+ timeTaken = new TimeSpan(Math.Max(timeTaken.Ticks, stageTime.Ticks));
+ }
+ else
+ {
+ scoreValid = false;
+ }
+ }
+
+ return scoreValid;
+ }
+
+ ///
+ /// Marks this challenge as failed
+ ///
+ public void Fail()
+ {
+ // Active stage is invalid/there are no stages
+ if (ActiveStage >= Stages.Count)
+ {
+ ActiveStage = Stages.Count;
+ Stages.Add(new Stage(Stages.Count, new List() { new CancelledObjective(0, 0) }));
+ return;
+ }
+
+ // Mark the stage as failed
+ Stages[ActiveStage].Fail();
+ }
+
+ public int UpdateStage(ulong steamId, out State currentState)
+ {
+ currentState = State.Complete;
+ ActiveStage = 0;
+
+ while (ActiveStage < Stages.Count && currentState == State.Complete)
+ {
+ var stage = Stages[ActiveStage];
+ currentState = stage.CurrentState();
+ switch (currentState)
+ {
+ case State.NotStarted:
+ // Start this stage
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"Starting stage: {stage.Objectives.Count} objectives");
+ stage.Objectives.ForEach(objective => objective.Start());
+ currentState = State.InProgress;
+ break;
+ case State.InProgress:
+ // This stage is in progress. Report the progress
+ break;
+ case State.Failed:
+ // This stage has failed. Report the state
+ // Make sure we stop any outstanding objectives so they don't keep trying to track updates
+ stage.Objectives.ForEach(objective => objective.Stop(State.Failed));
+ break;
+ case State.Complete:
+ // Completed, so we can go to next stage
+ ActiveStage++;
+ // Make sure we stop any limit objectives (such as the timer) so we don't keep ticking that down
+ stage.Objectives.ForEach(objective => objective.Stop(State.Complete));
+ break;
+ }
+ }
+
+ if (ActiveStage == Stages.Count && currentState == State.Complete)
+ {
+ currentState = State.ChallengeComplete;
+ }
+ return ActiveStage;
+ }
+}
\ No newline at end of file
diff --git a/XPRising/Models/Challenges/Stage.cs b/XPRising/Models/Challenges/Stage.cs
new file mode 100644
index 0000000..3605178
--- /dev/null
+++ b/XPRising/Models/Challenges/Stage.cs
@@ -0,0 +1,101 @@
+using XPRising.Models.ObjectiveTrackers;
+
+namespace XPRising.Models.Challenges;
+
+public class Stage
+{
+ public int Index { get; private set; }
+ public List Objectives { get; private set; }
+
+ public Stage(int index, List objectives)
+ {
+ Index = index;
+ Objectives = objectives;
+ }
+
+ public bool CalculateScore(out TimeSpan timeTaken, out float score)
+ {
+ timeTaken = TimeSpan.Zero;
+ score = 0;
+ var scoreValid = true;
+
+ foreach (var objective in Objectives)
+ {
+ switch (objective.Status)
+ {
+ case State.NotStarted:
+ // skip this objective if not started
+ continue;
+ case State.Failed:
+ // Score not valid
+ scoreValid = false;
+ timeTaken = TimeSpan.Zero;
+ score = 0;
+ break;
+ case State.InProgress:
+ // Score is added
+ score += objective.Score;
+ // time is ignored as this is not yet complete
+ break;
+ case State.Complete:
+ case State.ChallengeComplete:
+ // Score is added
+ score += objective.Score;
+ // time uses max
+ timeTaken = new TimeSpan(Math.Max(timeTaken.Ticks, objective.TimeTaken.Ticks));
+ break;
+ }
+ }
+
+ return scoreValid;
+ }
+
+ ///
+ /// Fails any outstanding objective trackers to mark this stage as failed
+ ///
+ public void Fail()
+ {
+ Objectives.ForEach(objective => objective.Stop(State.Failed));
+ }
+
+ public State CurrentState()
+ {
+ if (Objectives.Count == 0) return State.Complete;
+
+ var status = Objectives[0].Status;
+ foreach (var objective in Objectives)
+ {
+ // // Limit objectives are only relevant when they get listed as failed
+ // if (objective.IsLimit && objective.Status != State.Failed) continue;
+
+ switch (objective.Status)
+ {
+ case State.NotStarted:
+ // Any other state is more important than this one, so it will not replace the status
+ break;
+ case State.InProgress:
+ // Always set status as in progress if we hit that
+ status = State.InProgress;
+ break;
+ case State.Failed:
+ // Immediately return if some objective has failed
+ return State.Failed;
+ case State.Complete:
+ // Do nothing. Either we match and nothing changes or the main status does not match, so we keep that.
+ break;
+ }
+ }
+
+ return status;
+ }
+
+ public float CurrentProgress()
+ {
+ // "Limit" objectives should not be counted for progress
+ var objectivesWithProgress = Objectives
+ .Where(objective => objective.AddsStageProgress && objective.Progress >= 0)
+ .Select(objective => objective.Progress)
+ .ToList();
+ return objectivesWithProgress.Any() ? objectivesWithProgress.Average() : -1f;
+ }
+}
\ No newline at end of file
diff --git a/XPRising/Models/DefaultLocalisations.cs b/XPRising/Models/DefaultLocalisations.cs
index c229c8e..453993c 100644
--- a/XPRising/Models/DefaultLocalisations.cs
+++ b/XPRising/Models/DefaultLocalisations.cs
@@ -425,6 +425,10 @@ public static class DefaultLocalisations
L10N.TemplateKey.BarXp,
$"XP: {{earned}}/{{needed}}"
},
+ {
+ L10N.TemplateKey.BarXpMax,
+ $"XP: Max level"
+ },
{
L10N.TemplateKey.BarWeaponUnarmed,
$"Unarmed"
@@ -541,6 +545,10 @@ public static class DefaultLocalisations
L10N.TemplateKey.BarBloodCorruption,
$"Corrupted blood"
},
+ {
+ L10N.TemplateKey.BloodVBlood,
+ $"V blood"
+ },
{
L10N.TemplateKey.BarFactionBandits,
$"Bandits"
@@ -577,6 +585,50 @@ public static class DefaultLocalisations
L10N.TemplateKey.BarFactionWerewolf,
$"Werewolves"
},
+ {
+ L10N.TemplateKey.ChallengeUpdate,
+ $"Challenge updated"
+ },
+ {
+ L10N.TemplateKey.ChallengeStageComplete,
+ $"Challenge stage complete!"
+ },
+ {
+ L10N.TemplateKey.ChallengeProgress,
+ $"Challenge progress (stage {{stage}}): {{progress}}"
+ },
+ {
+ L10N.TemplateKey.ChallengeInProgress,
+ $"Challenge"
+ },
+ {
+ L10N.TemplateKey.ChallengeFailed,
+ $"Challenge failed!"
+ },
+ {
+ L10N.TemplateKey.ChallengeComplete,
+ $"Challenge completed!"
+ },
+ {
+ L10N.TemplateKey.ChallengeListHeader,
+ $"Challenges:"
+ },
+ {
+ L10N.TemplateKey.ChallengeNotFound,
+ $"Challenge not found"
+ },
+ {
+ L10N.TemplateKey.ChallengeNotRepeatable,
+ $"Challenge not repeatable"
+ },
+ {
+ L10N.TemplateKey.ChallengeLeaderboard,
+ $"Leaderboard ({{challenge}})"
+ },
+ {
+ L10N.TemplateKey.ChallengeLeaderboardEmpty,
+ $"No players have completed challenge!"
+ }
}
};
diff --git a/XPRising/Models/ObjectiveTrackers/KillTracker.cs b/XPRising/Models/ObjectiveTrackers/KillTracker.cs
new file mode 100644
index 0000000..7074c22
--- /dev/null
+++ b/XPRising/Models/ObjectiveTrackers/KillTracker.cs
@@ -0,0 +1,218 @@
+using BepInEx.Logging;
+using ProjectM;
+using ProjectM.Network;
+using XPRising.Systems;
+using XPRising.Transport;
+using XPRising.Utils;
+using XPRising.Utils.Prefabs;
+using XPShared;
+using XPShared.Events;
+using Faction = XPRising.Utils.Prefabs.Faction;
+
+namespace XPRising.Models.ObjectiveTrackers;
+
+public class KillObjectiveTracker : IObjectiveTracker
+{
+ public int StageIndex { get; }
+ public int Index { get; }
+ public string Objective { get; private set; }
+ public float Progress { get; private set; }
+ public State Status { get; private set; }
+ public TimeSpan TimeTaken { get; private set; }
+ public bool AddsStageProgress => _killsRequired > 0;
+ // Score is reported as 0 when tracking towards a limit (i.e. pass/fail), otherwise it reports the kill count
+ public float Score => _killsRequired > 0 ? 0 : _killCount;
+
+ private readonly string _challengeId;
+ private readonly ulong _steamId;
+ private readonly float _killsRequired; // Using float so we can don't get loss of fraction when calculating progress
+ private readonly List _factions;
+ private readonly List _bloodTypes;
+ private readonly string _targetsTooltip; // Describes which factions/units should be targeted
+ private readonly Action _handler;
+ private int _killCount;
+ private DateTime _startTime = DateTime.MinValue;
+
+ public KillObjectiveTracker(string challengeId, ulong steamId, int index, int stageIndex, int killCount, List factions, List bloodTypes)
+ {
+ _challengeId = challengeId;
+ _steamId = steamId;
+ StageIndex = stageIndex;
+ Index = index;
+ _killsRequired = killCount;
+
+ _factions = ValidateFactions(factions);
+ if (_factions.Count > 0)
+ {
+ var userPreferences = Database.PlayerPreferences[steamId];
+ // Convert the list of factions
+ _targetsTooltip = $" ({string.Join(",", _factions.Select(faction => ClientActionHandler.FactionTooltip(faction, userPreferences.Language)).OrderBy(x => x))})";
+ }
+
+ _bloodTypes = ValidateBloodTypes(bloodTypes);
+ if (_bloodTypes.Count > 0)
+ {
+ var userPreferences = Database.PlayerPreferences[steamId];
+ // Convert the list of factions
+ _targetsTooltip = $" ({string.Join(",", _bloodTypes.Select(type => {
+ var message = type switch
+ {
+ BloodType.Brute => L10N.Get(L10N.TemplateKey.BarBloodBrute),
+ BloodType.Corruption => L10N.Get(L10N.TemplateKey.BarBloodCorruption),
+ BloodType.Creature => L10N.Get(L10N.TemplateKey.BarBloodCreature),
+ BloodType.Draculin => L10N.Get(L10N.TemplateKey.BarBloodDraculin),
+ BloodType.Mutant => L10N.Get(L10N.TemplateKey.BarBloodMutant),
+ BloodType.Rogue => L10N.Get(L10N.TemplateKey.BarBloodRogue),
+ BloodType.Scholar => L10N.Get(L10N.TemplateKey.BarBloodScholar),
+ BloodType.VBlood => L10N.Get(L10N.TemplateKey.BloodVBlood),
+ BloodType.Warrior => L10N.Get(L10N.TemplateKey.BarBloodWarrior),
+ BloodType.Worker => L10N.Get(L10N.TemplateKey.BarBloodWorker),
+ // Note: All other blood types will hit default but this shouldn't happen as we have normalised it above
+ _ => new L10N.LocalisableString("Unknown")
+ };
+ return message.Build(userPreferences.Language);
+ }
+ ).OrderBy(x => x))})";
+ }
+
+ if (killCount > 0)
+ {
+ Objective = $"Kill: {killCount} mobs{_targetsTooltip}";
+ }
+ else
+ {
+ Objective = $"Kill!{_targetsTooltip}";
+ Progress = -1f;
+ }
+ Status = State.NotStarted;
+ TimeTaken = TimeSpan.Zero;
+
+ _handler = this.TrackKill;
+ }
+
+ public void Start()
+ {
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"kill tracker start: {StageIndex}-{Index}");
+ // Create the appropriate subscriptions to ensure we can update our state
+ VEvents.ModuleRegistry.Subscribe(_handler);
+ if (!AddsStageProgress)
+ {
+ Status = State.Complete;
+ }
+ else if (Status == State.NotStarted)
+ {
+ Status = State.InProgress;
+ }
+ _startTime = DateTime.Now;
+ }
+
+ public void Stop(State endState)
+ {
+ // Clean up any subscriptions
+ VEvents.ModuleRegistry.Unsubscribe(_handler);
+ Status = endState;
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"kill tracker stop: {StageIndex}-{Index}");
+ // If this is the limit version, then record how long it took to reach that limit
+ if (_killsRequired > 0)
+ {
+ TimeTaken += (DateTime.Now - _startTime);
+ }
+ }
+
+ private void TrackKill(ServerEvents.CombatEvents.PlayerKillMob e)
+ {
+ var userEntity = e.Source.Read().UserEntity;
+ var killerUserComponent = userEntity.Read();
+ if (killerUserComponent.PlatformId != _steamId) return;
+
+ if (_factions.Count > 0)
+ {
+ if (!e.Target.HasValue)
+ {
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Warning, () => $"Player killed entity but target not set");
+ return;
+ }
+ if (!e.Target.Value.TryGetComponent(out var victimFactionReference))
+ {
+ Plugin.Log(Plugin.LogSystem.Faction, LogLevel.Warning, () => $"Player killed: Entity: {e.Target.Value}, but it has no faction");
+ return;
+ }
+
+ // Validate the faction is one we want
+ var victimFaction = victimFactionReference.FactionGuid._Value;
+ FactionHeat.GetActiveFaction(victimFaction, out var activeFaction);
+ if (!_factions.Contains(activeFaction)) return;
+ }
+ if (_bloodTypes.Count > 0)
+ {
+ if (!e.Target.HasValue)
+ {
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Warning, () => $"Player killed entity but target not set");
+ return;
+ }
+
+ var (bloodType, _, isVBlood) = Helper.GetBloodInfo(e.Target.Value);
+ var isValidBlood = isVBlood && _bloodTypes.Contains(BloodType.VBlood) || _bloodTypes.Contains(bloodType);
+ if (!isValidBlood) return;
+ }
+
+ _killCount++;
+ if (_killsRequired > 0)
+ {
+ Progress = Math.Min(_killCount / _killsRequired, 1.0f);
+ if (Progress >= 1.0f)
+ {
+ Stop(State.Complete);
+ }
+ }
+ else
+ {
+ Objective = $"Kill! x{_killCount}{_targetsTooltip}";
+ }
+
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"Tracking kill: {_killCount}/{_killsRequired:F0} ({Progress*100:F1}%)");
+ ChallengeSystem.UpdateChallenge(_challengeId, _steamId);
+ }
+
+ private static List ValidateFactions(List factions)
+ {
+ if (factions == null) return new List();
+ // make sure we match wanted system for internal consistency
+ return factions.Select((faction) =>
+ {
+ FactionHeat.GetActiveFaction(faction, out var activeFaction);
+ if (activeFaction == Faction.Unknown)
+ {
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Warning, () => $"Faction not currently supported for objectives: {faction}");
+ }
+ return activeFaction;
+ })
+ // Remove unknown factions (can add support for them into FactionHeat later, even if not exposed to WantedSystem as "active" factions)
+ .Where(faction => faction != Faction.Unknown)
+ // Get unique factions
+ .Distinct().ToList();
+ }
+
+ private static List ValidateBloodTypes(List bloodTypes)
+ {
+ if (bloodTypes == null) return new List();
+ return bloodTypes
+ .Select(bloodType =>
+ {
+ return bloodType switch
+ {
+ // GateBoss is really just VBlood (at this stage)
+ BloodType.DraculaTheImmortal => BloodType.VBlood,
+ BloodType.GateBoss => BloodType.VBlood,
+ // Unknown maps to none
+ BloodType.Unknown => BloodType.None,
+ // other types return as they are
+ _ => bloodType
+ };
+ })
+ // Remove unknown blood types
+ .Where(bloodType => bloodType != BloodType.None)
+ // Get unique blood types
+ .Distinct().ToList();
+ }
+}
\ No newline at end of file
diff --git a/XPRising/Models/ObjectiveTrackers/ObjectiveTracker.cs b/XPRising/Models/ObjectiveTrackers/ObjectiveTracker.cs
new file mode 100644
index 0000000..00b6f7d
--- /dev/null
+++ b/XPRising/Models/ObjectiveTrackers/ObjectiveTracker.cs
@@ -0,0 +1,68 @@
+namespace XPRising.Models.ObjectiveTrackers;
+
+public enum State
+{
+ NotStarted,
+ InProgress,
+ Failed,
+ Complete,
+ ChallengeComplete
+}
+
+public static class StateExtensions
+{
+ public static bool IsFinished(this State status)
+ {
+ return status == State.Failed || status == State.ChallengeComplete;
+ }
+}
+
+public interface IObjectiveTracker
+{
+ public int StageIndex { get; }
+ public int Index { get; }
+ public string Objective { get; }
+ public float Progress { get; }
+ public State Status { get; }
+ public bool AddsStageProgress => false;
+ public TimeSpan TimeTaken => TimeSpan.Zero;
+ public float Score => 0;
+
+ // Functions to start or stop the objective
+ public abstract void Start();
+ public abstract void Stop(State endState);
+}
+
+public class InvalidObjective : IObjectiveTracker
+{
+ public int StageIndex { get; }
+ public int Index { get; }
+ public string Objective => "Invalid objective";
+ public float Progress => 0;
+ public State Status => State.Failed;
+ public void Start() {}
+ public void Stop(State endState) {}
+
+ public InvalidObjective(int index, int stageIndex)
+ {
+ StageIndex = stageIndex;
+ Index = index;
+ }
+}
+
+public class CancelledObjective : IObjectiveTracker
+{
+ public int StageIndex { get; }
+ public int Index { get; }
+ public string Objective => "Cancelled";
+ public float Progress => 0;
+ public State Status => State.Failed;
+ public void Start() {}
+ public void Stop(State endState) {}
+
+ public CancelledObjective(int index, int stageIndex)
+ {
+ StageIndex = stageIndex;
+ Index = index;
+ }
+}
\ No newline at end of file
diff --git a/XPRising/Models/ObjectiveTrackers/TimeLimitTracker.cs b/XPRising/Models/ObjectiveTrackers/TimeLimitTracker.cs
new file mode 100644
index 0000000..49497fb
--- /dev/null
+++ b/XPRising/Models/ObjectiveTrackers/TimeLimitTracker.cs
@@ -0,0 +1,74 @@
+using XPRising.Systems;
+using XPRising.Transport;
+using XPShared;
+
+namespace XPRising.Models.ObjectiveTrackers;
+
+public class TimeLimitTracker : IObjectiveTracker
+{
+ public int StageIndex { get; }
+ public int Index { get; }
+ public string Objective => $"Time limit ({FormatTimeSpan(TimeRemaining)})";
+ public float Progress => _isCountDown ? (float)Math.Clamp(TimeRemaining.TotalSeconds / _span.TotalSeconds, 0, 1) : 1 - (float)Math.Clamp(TimeRemaining.TotalSeconds / _span.TotalSeconds, 0, 1);
+ public State Status { get; private set; }
+ // Score is currently always 0
+ public float Score => 0;
+
+ private TimeSpan TimeRemaining => _handler.Enabled ? _timeEnd < DateTime.Now ? TimeSpan.Zero : _timeEnd - DateTime.Now : _span;
+
+ private readonly string _challengeId;
+ private readonly ulong _steamId;
+ private readonly FrameTimer _handler;
+ private readonly TimeSpan _span;
+ private DateTime _timeEnd;
+ private bool _isCountDown;
+
+ public TimeLimitTracker(string challengeId, ulong steamId, int index, int stageIndex, TimeSpan span)
+ {
+ StageIndex = stageIndex;
+ Index = index;
+ _challengeId = challengeId;
+ _steamId = steamId;
+
+ Status = State.NotStarted;
+
+ _handler = new FrameTimer();
+ _handler.Initialise(UpdateChallenge, TimeSpan.FromSeconds(1), -1);
+ _span = span < TimeSpan.Zero ? -span : span;
+
+ _isCountDown = span > TimeSpan.Zero;
+ }
+
+ public void Start()
+ {
+ Status = _isCountDown ? State.Complete : State.InProgress;
+ _timeEnd = DateTime.Now + _span;
+ _handler.Start();
+ }
+
+ public void Stop(State endState)
+ {
+ _handler.Stop();
+ Status = endState;
+ }
+
+ private void UpdateChallenge()
+ {
+ if (_timeEnd < DateTime.Now)
+ {
+ var endState = _isCountDown ? State.Failed : State.Complete;
+ Stop(endState);
+ ChallengeSystem.UpdateChallenge(_challengeId, _steamId);
+ }
+ else
+ {
+
+ ClientActionHandler.SendChallengeTimerUpdate(_steamId, _challengeId, this);
+ }
+ }
+
+ private static string FormatTimeSpan(TimeSpan ts)
+ {
+ return ts.TotalHours >= 1 ? $@"{ts.TotalHours:F0}:{ts:mm\:ss}" : $@"{ts:mm\:ss}";
+ }
+}
\ No newline at end of file
diff --git a/XPRising/Models/PlayerHeatData.cs b/XPRising/Models/PlayerHeatData.cs
index 0413d66..f343b6f 100644
--- a/XPRising/Models/PlayerHeatData.cs
+++ b/XPRising/Models/PlayerHeatData.cs
@@ -20,7 +20,11 @@ public struct Heat {
public PlayerHeatData()
{
- _cooldownTimer.Initialise(RunCooldown, TimeSpan.FromMilliseconds(CooldownTickLengthMs), -1);
+ if (CooldownPerSecond <= 0)
+ {
+ Plugin.Log(Plugin.LogSystem.Wanted, LogLevel.Warning, $"cooldown disabled @ {CooldownPerSecond}/s");
+ }
+ _cooldownTimer.Initialise(RunCooldown, TimeSpan.FromMilliseconds(TimerTickLengthMs), -1);
}
public void Clear()
@@ -29,8 +33,11 @@ public void Clear()
heat.Clear();
}
- private static double CooldownPerSecond => WantedSystem.heat_cooldown < 1 ? 1 / 6f : WantedSystem.heat_cooldown / 60f;
- private static int CooldownTickLengthMs => (int)Math.Max(1000, 1000 / CooldownPerSecond);
+ private static double CooldownPerSecond => Math.Max(WantedSystem.HeatCooldown, 0) / 60f;
+ // Calculate an appropriate tick length, with a lower cooldown/s causing a longer tick length. Resulting range of [500, 5000]
+ // Need to clamp here to ensure we don't divide by 0.
+ // Config technically supports negative
+ private static int TimerTickLengthMs => (int)(1000 / Math.Clamp(Math.Abs(CooldownPerSecond), 0.2, 2));
private void RunCooldown()
{
@@ -42,8 +49,8 @@ private void RunCooldown()
var userLanguage = Database.PlayerPreferences[_steamID].Language;
if (WantedSystem.CanCooldownHeat(lastCombatStart, lastCombatEnd)) {
- var cooldownValue = (int)Math.Round(CooldownTickLengthMs * 0.001f * CooldownPerSecond);
- Plugin.Log(Plugin.LogSystem.Wanted, LogLevel.Info, $"Heat cooldown: {cooldownValue} ({CooldownPerSecond:F1}c/s)");
+ var cooldownValue = (int)Math.Round(TimerTickLengthMs * 0.001f * CooldownPerSecond);
+ Plugin.Log(Plugin.LogSystem.Wanted, LogLevel.Info, $"Heat cooldown: {cooldownValue} ({CooldownPerSecond:F1}/s)");
// Update all heat levels
foreach (var faction in heat.Keys) {
@@ -77,6 +84,6 @@ public void StartCooldownTimer(ulong steamID)
_steamID = steamID;
}
- if (!_cooldownTimer.Enabled) _cooldownTimer.Start();
+ if (!_cooldownTimer.Enabled && CooldownPerSecond > 0) _cooldownTimer.Start();
}
}
\ No newline at end of file
diff --git a/XPRising/Models/PlayerPreferences.cs b/XPRising/Models/PlayerPreferences.cs
index b39c23d..c1c7143 100644
--- a/XPRising/Models/PlayerPreferences.cs
+++ b/XPRising/Models/PlayerPreferences.cs
@@ -10,11 +10,16 @@ public struct PlayerPreferences
private const string DefaultXpColour = "#ffcc33";
private const string DefaultMasteryColour = "#ccff33";
private const string DefaultBloodMasteryColour = "#cc0000";
+ private const string DefaultChallengeActiveColour = "#ffcc33";
+ private const string DefaultChallengeFailedColour = "#cc0000";
+ private const string DefaultChallengeInactiveColour = "#555555";
public bool LoggingWanted = false;
public bool LoggingExp = false;
public bool LoggingMastery = false;
+ public bool LoggingChallenges = false;
public bool IgnoringInvites = false;
+ public bool ScrollingCombatText = true;
public string Language = L10N.DefaultLanguage;
public int TextSize = Plugin.DefaultTextSize;
public Actions.BarState UIProgressDisplay = Actions.BarState.Active;
@@ -22,8 +27,11 @@ public struct PlayerPreferences
[JsonIgnore] public string XpBarColour => BarColours.ElementAtOrDefault(0) ?? DefaultXpColour;
[JsonIgnore] public string MasteryBarColour => BarColours.ElementAtOrDefault(1) ?? DefaultMasteryColour;
[JsonIgnore] public string BloodMasteryBarColour => BarColours.ElementAtOrDefault(2) ?? DefaultBloodMasteryColour;
+ [JsonIgnore] public string ChallengeActiveBarColour => BarColours.ElementAtOrDefault(3) ?? DefaultChallengeActiveColour;
+ [JsonIgnore] public string ChallengeFailedBarColour => BarColours.ElementAtOrDefault(4) ?? DefaultChallengeFailedColour;
+ [JsonIgnore] public string ChallengeInactiveBarColour => BarColours.ElementAtOrDefault(3) ?? DefaultChallengeInactiveColour;
[JsonIgnore]
- public string[] BarColoursWithDefaults => new string[] {XpBarColour, MasteryBarColour, BloodMasteryBarColour};
+ public string[] BarColoursWithDefaults => new string[] {XpBarColour, MasteryBarColour, BloodMasteryBarColour, ChallengeActiveBarColour, ChallengeFailedBarColour, ChallengeInactiveBarColour};
public PlayerPreferences()
{
diff --git a/XPRising/Plugin.cs b/XPRising/Plugin.cs
index 83f84dd..ba6da06 100644
--- a/XPRising/Plugin.cs
+++ b/XPRising/Plugin.cs
@@ -33,6 +33,7 @@ public class Plugin : BasePlugin
public static bool IsInitialized = false;
public static bool BloodlineSystemActive = false;
+ public static bool ChallengeSystemActive = true;
public static bool ExperienceSystemActive = true;
public static bool PlayerGroupsActive = true;
public static int MaxPlayerGroupSize = 5;
@@ -89,6 +90,7 @@ public void InitCoreConfig()
DefaultTextSize = PlayerPreferences.ConvertTextToSize(textSizeString);
BloodlineSystemActive = Config.Bind("System", "Enable Bloodline Mastery system", false, "Enable/disable the bloodline mastery system.").Value;
+ ChallengeSystemActive = Config.Bind("System", "Enable Challenge system", true, "Enable/disable the challenge system.").Value;
ExperienceSystemActive = Config.Bind("System", "Enable Experience system", true, "Enable/disable the experience system.").Value;
PlayerGroupsActive = Config.Bind("System", "Enable Player Groups", true, "Enable/disable the player group system.").Value;
MaxPlayerGroupSize = Config.Bind("System", "Maximum player group size", 5, "Set a maximum value for player group size.").Value;
@@ -151,6 +153,7 @@ public override void Load()
CommandUtility.AddCommandType(typeof(PlayerInfoCommands));
CommandUtility.AddCommandType(typeof(WantedCommands), WantedSystemActive);
CommandUtility.AddCommandType(typeof(LocalisationCommands));
+ CommandUtility.AddCommandType(typeof(ChallengeCommands));
if (IsDebug)
{
@@ -214,6 +217,8 @@ public static void Initialize()
if (ExperienceSystemActive) ExperienceConfig.Initialize();
if (WantedSystemActive) WantedConfig.Initialize();
+ if (ChallengeSystemActive) ChallengeSystem.Initialise();
+
//-- Apply configs
Plugin.Log(LogSystem.Core, LogLevel.Info, "Initialising player cache and internal database...");
@@ -237,6 +242,12 @@ public static void Initialize()
RandomEncounters.StartEncounterTimer();
}
+ if (ChallengeSystemActive)
+ {
+ // Validate challenges
+ ChallengeSystem.ValidateChallenges();
+ }
+
Plugin.Log(LogSystem.Core, LogLevel.Info, "Finished initialising", true);
IsInitialized = true;
@@ -253,6 +264,7 @@ public enum LogSystem
Alliance,
Bloodline,
Buff,
+ Challenge,
Core,
Death,
Debug,
diff --git a/XPRising/README_TS.md b/XPRising/README_TS.md
index 99d73b4..e152968 100644
--- a/XPRising/README_TS.md
+++ b/XPRising/README_TS.md
@@ -19,6 +19,9 @@ There is an optional (but recommended) [companion UI](https://thunderstore.io/c/
### Wanted system
- A system that tracks player kills against different factions in the game and causes factions to ambush players with enemies as their "heat" level increases.
+### Challenge system
+- A system to set up challenges for players and records scores that are used on a leaderboard to rank players
+
### Installation
- Install [BepInEx](https://thunderstore.io/c/v-rising/p/BepInEx/BepInExPack_V_Rising/).
diff --git a/XPRising/Systems/BloodlineSystem.cs b/XPRising/Systems/BloodlineSystem.cs
index 4d859c9..ef0d346 100644
--- a/XPRising/Systems/BloodlineSystem.cs
+++ b/XPRising/Systems/BloodlineSystem.cs
@@ -49,76 +49,69 @@ public static void UpdateBloodline(Entity killer, Entity victim, bool killOnly)
double growthVal = Math.Clamp(victimLevel.Level.Value - ExperienceSystem.GetLevel(steamID), 1, 10);
- GlobalMasterySystem.MasteryType killerBloodType;
- if (_em.TryGetComponentData(killer, out var killerBlood)){
- if (!GuidToBloodType(killerBlood.BloodType, out killerBloodType)) return;
- }
- else {
+ var (killerBloodType, killerBloodQuality, isKillerVBlood) = Helper.GetBloodInfo(killer);
+ if (killerBloodType == BloodType.Unknown || isKillerVBlood){
Plugin.Log(LogSystem.Bloodline, LogLevel.Info, $"killer does not have blood: Killer ({killer}), Victim ({victim})");
return;
}
- GlobalMasterySystem.MasteryType victimBloodType;
- float victimBloodQuality;
- bool isVBlood;
+ GlobalMasterySystem.MasteryType playerMasteryToUpdate = GlobalMasterySystem.MasteryType.None;
var growthModifier = killOnly ? 0.4 : 1.0;
- if (_em.TryGetComponentData(victim, out var victimBlood)) {
- victimBloodQuality = victimBlood.BloodQuality;
- isVBlood = Helper.IsVBlood(victimBlood);
- if (isVBlood)
+ var (victimBloodType, victimBloodQuality, isVictimVBlood) = Helper.GetBloodInfo(victim);
+ if (victimBloodType == BloodType.Unknown)
+ {
+ Plugin.Log(LogSystem.Bloodline, LogLevel.Info, $"victim does not have blood: Killer ({killer}), Victim ({victim}");
+ return;
+ }
+
+ if (isVictimVBlood)
+ {
+ victimBloodQuality = 100f;
+ growthModifier = VBloodMultiplier;
+ // When running the kill only step for VBloods, only add to the current bloodline, not multi-bloodlines
+ if (VBloodAddsXTypes > 0 && !killOnly)
{
- victimBloodQuality = 100f;
- growthModifier = VBloodMultiplier;
- // When running the kill only step for VBloods, only add to the current bloodline, not multi-bloodlines
- if (VBloodAddsXTypes > 0 && !killOnly)
+ var pmd = Database.PlayerMastery[steamID];
+ if (VBloodAddsXTypes >= BloodTypeCount)
{
- var pmd = Database.PlayerMastery[steamID];
- if (VBloodAddsXTypes >= BloodTypeCount)
+ Plugin.Log(LogSystem.Bloodline, LogLevel.Info, () => $"Adding V Blood bonus to all blood types.");
+ foreach (var bloodType in BuffToBloodTypeMap.Values)
{
- Plugin.Log(LogSystem.Bloodline, LogLevel.Info, () => $"Adding V Blood bonus to all blood types.");
- foreach (var bloodType in BuffToBloodTypeMap.Values)
- {
- var bloodTypeGrowth = growthVal * BloodGrowthMultiplier(growthModifier, victimBloodQuality);
- GlobalMasterySystem.BankMastery(steamID, victim, bloodType, ApplyMasteryMultiplier(bloodType, bloodTypeGrowth));
- }
+ var bloodTypeGrowth = growthVal * BloodGrowthMultiplier(growthModifier, victimBloodQuality);
+ GlobalMasterySystem.BankMastery(steamID, victim, bloodType, ApplyMasteryMultiplier(bloodType, bloodTypeGrowth));
}
- else
- {
- var selectedBloodTypes =
- BuffToBloodTypeMap.Values.OrderBy(x => _random.Next()).Take(VBloodAddsXTypes);
- Plugin.Log(LogSystem.Bloodline, LogLevel.Info, () => $"Adding V Blood bonus to {VBloodAddsXTypes} blood types: {string.Join(",", selectedBloodTypes)}");
- foreach (var bloodType in selectedBloodTypes)
- {
- var bloodTypeGrowth = growthVal * BloodGrowthMultiplier(growthModifier, victimBloodQuality);
- GlobalMasterySystem.BankMastery(steamID, victim, bloodType, ApplyMasteryMultiplier(bloodType, bloodTypeGrowth));
- }
- }
- return;
}
else
{
- victimBloodType = killerBloodType;
+ var selectedBloodTypes =
+ BuffToBloodTypeMap.Values.OrderBy(x => _random.Next()).Take(VBloodAddsXTypes);
+ Plugin.Log(LogSystem.Bloodline, LogLevel.Info, () => $"Adding V Blood bonus to {VBloodAddsXTypes} blood types: {string.Join(",", selectedBloodTypes)}");
+ foreach (var bloodType in selectedBloodTypes)
+ {
+ var bloodTypeGrowth = growthVal * BloodGrowthMultiplier(growthModifier, victimBloodQuality);
+ GlobalMasterySystem.BankMastery(steamID, victim, bloodType, ApplyMasteryMultiplier(bloodType, bloodTypeGrowth));
+ }
}
+ return;
}
else
{
- GuidToBloodType(victimBlood.UnitBloodType, out victimBloodType);
+ playerMasteryToUpdate = BloodToMastery(killerBloodType);
}
}
else
{
- Plugin.Log(LogSystem.Bloodline, LogLevel.Info, $"victim does not have blood: Killer ({killer}), Victim ({victim}");
- return;
+ playerMasteryToUpdate = BloodToMastery(victimBloodType);
}
- if (victimBloodType == GlobalMasterySystem.MasteryType.None)
+ if (playerMasteryToUpdate == GlobalMasterySystem.MasteryType.None)
{
Plugin.Log(LogSystem.Bloodline, LogLevel.Info, $"victim has frail blood, not modifying: Killer ({killer}), Victim ({victim})");
return;
}
var playerMasterydata = Database.PlayerMastery[steamID];
- var bloodlineMastery = playerMasterydata[victimBloodType];
+ var bloodlineMastery = playerMasterydata[playerMasteryToUpdate];
growthVal *= BloodGrowthMultiplier(growthModifier, victimBloodQuality);
if (MercilessBloodlines && victimBloodQuality <= bloodlineMastery.Mastery)
@@ -148,33 +141,24 @@ public static void UpdateBloodline(Entity killer, Entity victim, bool killOnly)
Plugin.Log(LogSystem.Bloodline, LogLevel.Info, $"Bonus bloodline mastery {bonusMastery:F3}]");
}
- growthVal = ApplyMasteryMultiplier(victimBloodType, growthVal);
+ growthVal = ApplyMasteryMultiplier(playerMasteryToUpdate, growthVal);
- GlobalMasterySystem.BankMastery(steamID, victim, victimBloodType, growthVal);
+ GlobalMasterySystem.BankMastery(steamID, victim, playerMasteryToUpdate, growthVal);
}
public static GlobalMasterySystem.MasteryType BloodMasteryType(Entity entity)
{
- var bloodType = GlobalMasterySystem.MasteryType.None;
- if (_em.TryGetComponentData(entity, out var entityBlood))
- {
- GuidToBloodType(entityBlood.BloodType, out bloodType);
- }
- return bloodType;
+ var (bloodType, _, _) = Helper.GetBloodInfo(entity);
+ return BloodToMastery(bloodType);
}
- private static bool GuidToBloodType(PrefabGUID guid, out GlobalMasterySystem.MasteryType bloodType)
+ private static GlobalMasterySystem.MasteryType BloodToMastery(BloodType blood)
{
- bloodType = GlobalMasterySystem.MasteryType.None;
- if (guid.GuidHash == (int)Remainders.BloodType_VBlood || guid.GuidHash == (int)Remainders.BloodType_GateBoss)
- return false;
- if(!Enum.IsDefined(typeof(GlobalMasterySystem.MasteryType), guid.GuidHash)) {
- Plugin.Log(LogSystem.Bloodline, LogLevel.Warning, $"Bloodline not found for guid {guid.GuidHash}", true);
- return false;
+ if (blood == BloodType.None) {
+ return GlobalMasterySystem.MasteryType.None;
}
- bloodType = (GlobalMasterySystem.MasteryType)guid.GuidHash;
- return true;
+ return (GlobalMasterySystem.MasteryType)blood;
}
private static double BloodGrowthMultiplier(double modifier, double quality)
diff --git a/XPRising/Systems/ChallengeSystem.cs b/XPRising/Systems/ChallengeSystem.cs
new file mode 100644
index 0000000..6e798c0
--- /dev/null
+++ b/XPRising/Systems/ChallengeSystem.cs
@@ -0,0 +1,493 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+using BepInEx.Logging;
+using XPRising.Models;
+using XPRising.Models.Challenges;
+using XPRising.Models.ObjectiveTrackers;
+using XPRising.Transport;
+using XPRising.Utils;
+using XPRising.Utils.Prefabs;
+using XPShared;
+using Faction = XPRising.Utils.Prefabs.Faction;
+
+namespace XPRising.Systems;
+
+public static class ChallengeSystem
+{
+ public struct Objective
+ {
+ /*
+ * Challenge options without variable support yet:
+ * - spell school use
+ * - player blood type
+ * - zone (eg, mortium vs farbane woods)
+ * - location
+ * - survive
+ * - time of day (e.g. kills at night vs kills during day)
+ * - level difference range
+ */
+ // Kills required
+ public int killCount;
+ // Damage done
+ public float damageCount;
+ // List of units accepted for counting as kills/damage
+ public List unitTypes;
+ // List of factions accepted for counting as kills/damage
+ public List factions;
+ // List of blood types accepted for counting as kills/damage
+ public List unitBloodType;
+ // Minimum blood level accepted for kills/damage
+ public float bloodLevel;
+ // List of weapon/spell types accepted for kills/damage
+ // - would be good to extend masteries to include individual spell schools for this type
+ public List masteryTypes;
+ // Required time limit
+ // - (positive) requires kills/damage to be completed in time
+ // - (negative) records score generated from kills/damage within time limit
+ public TimeSpan limit;
+ // Challenge checked for completion at given time
+ // - used for dynamically created challenges (i.e. players must be at location at given time before continuing to next stage)
+ public DateTime time;
+ // Used for multiplayer. Player must have placement > x to continue in challenge (i.e. knockout style)
+ public int placement;
+ }
+
+ public struct Challenge
+ {
+ public string ID;
+ public string Label;
+ public List> Objectives;
+ [JsonIgnore(Condition = JsonIgnoreCondition.Never)]
+ public bool CanRepeat;
+ // Reward?
+ }
+
+ public class ChallengeConfig
+ {
+ public List Challenges;
+ public List ChallengeTemplates;
+ }
+
+ public struct ChallengeStats
+ {
+ public DateTime FirstCompleted;
+ public DateTime LastCompleted;
+ public int Attempts;
+ public int CompleteCount;
+ public TimeSpan FastestTime;
+ public int Score;
+ }
+
+ public static ChallengeConfig ChallengeDatabase;
+ public static LazyDictionary> PlayerChallengeStats = new();
+
+ private static readonly LazyDictionary> PlayerActiveChallenges = new();
+
+ private struct ChallengeEnded
+ {
+ public string challengeId;
+ public ulong steamId;
+ public State endStatus;
+ public DateTime removeTime;
+ }
+
+ // A list of challenges that have failed/completed so that we can remove them from the active list in the UI
+ private static readonly List ChallengesToRemove = new();
+ private static readonly FrameTimer RemoveTimer = new FrameTimer();
+
+ public static bool IsPlayerLoggingChallenges(ulong steamId)
+ {
+ return Database.PlayerPreferences[steamId].LoggingChallenges;
+ }
+
+ public static void Initialise()
+ {
+ if (Plugin.ChallengeSystemActive)
+ {
+ RemoveTimer.Initialise(UpdateRemovedChallenges, TimeSpan.FromMilliseconds(500), -1);
+ RemoveTimer.Start();
+ }
+ }
+
+ public static ReadOnlyCollection<(Challenge challenge, State status)> ListChallenges(ulong steamId, bool hideCompleted = true)
+ {
+ var availableChallenges = new List<(Challenge challenge, State status)>();
+ var activeChallenges = PlayerActiveChallenges[steamId];
+ var oldChallenges = PlayerChallengeStats[steamId];
+ foreach (var challenge in ChallengeDatabase.Challenges)
+ {
+ var status = State.NotStarted;
+ if (activeChallenges.TryGetValue(challenge.ID, out var state))
+ {
+ status = state.Stages.Select(stage => stage.CurrentState())
+ .FirstOrDefault(s => s != State.Complete, State.Complete);
+ }
+ else if (oldChallenges.TryGetValue(challenge.ID, out var stats))
+ {
+ if (stats.CompleteCount > 0)
+ {
+ // If we have completed this previously and it can't be repeated, ignore it here.
+ if (!challenge.CanRepeat && hideCompleted) continue;
+
+ status = State.Complete;
+ }
+ else if (stats.Attempts > 0)
+ {
+ status = challenge.CanRepeat ? State.NotStarted : State.Failed;
+ }
+ else
+ {
+ status = State.NotStarted;
+ }
+ }
+
+ availableChallenges.Add((challenge, status));
+ }
+ return availableChallenges.AsReadOnly();
+ }
+
+ public static void ToggleChallenge(ulong steamId, int index)
+ {
+ if (index < ChallengeDatabase.Challenges.Count)
+ {
+ var challenge = ChallengeDatabase.Challenges[index];
+
+ // Stop users from adding challenges twice
+ var activeChallenges = PlayerActiveChallenges[steamId];
+ if (activeChallenges.TryGetValue(challenge.ID, out var activeState))
+ {
+ if (!activeState.CurrentState().IsFinished())
+ {
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"{challenge.ID} already active: {steamId}");
+ // Mark this as failed as the player is rejecting it
+ activeState.Fail();
+
+ // Update the challenge as it will be marked as failed
+ UpdateChallenge(challenge.ID, steamId);
+ return;
+ }
+ // if this challenge is finished, then it will be listed in the stats section and we handle it there for other cases
+ }
+
+ // Stop users from restarting failed/completed challenges that are not repeatable
+ var oldChallenges = PlayerChallengeStats[steamId];
+ if (oldChallenges.ContainsKey(challenge.ID) && !challenge.CanRepeat)
+ {
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"{challenge.ID} not repeatable: {steamId}");
+ Output.SendMessage(steamId, L10N.Get(L10N.TemplateKey.ChallengeNotRepeatable));
+ return;
+ }
+
+ var stages = challenge.Objectives.Select((stage, stageIndex) =>
+ {
+ var objectives = new List();
+ var limit = TimeSpan.Zero;
+ foreach (var objective in stage)
+ {
+ if (objective.killCount != 0)
+ {
+ objectives.Add(new KillObjectiveTracker(challenge.ID, steamId, objectives.Count, stageIndex, objective.killCount, objective.factions, objective.unitBloodType));
+ }
+
+ if (objective.limit.TotalSeconds != 0)
+ {
+ // Update the limit if it is zero (not yet set) or if the new limit is smaller.
+ limit = limit == TimeSpan.Zero || limit > objective.limit ? objective.limit : limit;
+ }
+ }
+
+ if (objectives.Count == 0)
+ {
+ objectives.Add(new InvalidObjective(0, stageIndex));
+ }
+ // Only add a limit if there is an actual objective
+ else if (limit.TotalSeconds != 0)
+ {
+ objectives.Add(new TimeLimitTracker(challenge.ID, steamId, objectives.Count, stageIndex, limit));
+ }
+ return new Stage(stageIndex, objectives);
+ });
+ var activeChallenge = new ChallengeState()
+ {
+ ChallengeId = challenge.ID,
+ Stages = stages.ToList()
+ };
+ // Add the state to the known player challenges
+ activeChallenges[challenge.ID] = activeChallenge;
+
+ // Update the stats as well
+ var stats = oldChallenges[challenge.ID];
+ stats.Attempts++;
+ oldChallenges[challenge.ID] = stats;
+
+ // Update the challenge as started
+ UpdateChallenge(challenge.ID, steamId);
+ }
+ else
+ {
+ Output.SendMessage(steamId, L10N.Get(L10N.TemplateKey.ChallengeNotFound));
+ }
+ }
+
+ public static void ToggleChallenge(ulong steamId, string challengeId)
+ {
+ for (var i = 0; i < ChallengeDatabase.Challenges.Count; ++i)
+ {
+ if (ChallengeDatabase.Challenges[i].ID == challengeId)
+ {
+ ToggleChallenge(steamId, i);
+ return;
+ }
+ }
+ }
+
+ public static ReadOnlyCollection<(ulong, ChallengeStats)> ListChallengeStats(int index, int top, out Challenge challenge)
+ {
+ var challengeStats = new List<(ulong, ChallengeStats)>();
+ challenge = new Challenge();
+
+ if (index >= ChallengeDatabase.Challenges.Count) return challengeStats.AsReadOnly();
+
+ challenge = ChallengeDatabase.Challenges[index];
+ foreach (var (playerStatId, playerStats) in PlayerChallengeStats)
+ {
+ // Ignore this entry if the player has not attempted it or if they have not completed it
+ if (!playerStats.TryGetValue(challenge.ID, out var stats) || stats.CompleteCount == 0) continue;
+
+ challengeStats.Add((playerStatId, stats));
+ }
+ // OrderBy time (ASC) ThenBy score (DESC)
+ return challengeStats.OrderBy(x => x.Item2.FastestTime).ThenByDescending(x => x.Item2.Score).Take(top).ToList().AsReadOnly();
+ }
+
+ public static void UpdateChallenge(string challengeId, ulong steamId)
+ {
+ var playerChallenges = PlayerActiveChallenges[steamId];
+
+ // Handle the case where there are no stages to this challenge (just return)
+ if (!playerChallenges.TryGetValue(challengeId, out var challengeUpdated) || challengeUpdated.Stages.Count == 0) return;
+
+ // Find the current active stage
+ var activeStageIndex = challengeUpdated.UpdateStage(steamId, out var currentState);
+
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"Challenge updated: {steamId}-{activeStageIndex}-{currentState}");
+
+ // The active stage has progressed passed the end (so all the stages have been completed)
+ if (activeStageIndex == challengeUpdated.Stages.Count)
+ {
+ // Send an update to the UI about completing the previous stage
+ LogChallengeUpdate(steamId, challengeUpdated.Stages[activeStageIndex - 1], State.ChallengeComplete);
+ var challengeStats = PlayerChallengeStats[steamId];
+ var stats = challengeStats[challengeId];
+ stats.CompleteCount++;
+ stats.LastCompleted = DateTime.Now;
+
+ challengeUpdated.CalculateScore(out var timeTaken, out var score);
+ if (stats.FirstCompleted == DateTime.MinValue)
+ {
+ stats.FirstCompleted = stats.LastCompleted;
+ stats.FastestTime = timeTaken;
+ stats.Score = score;
+ }
+ else if (stats.FastestTime > timeTaken)
+ {
+ stats.FastestTime = timeTaken;
+ stats.Score = score;
+ }
+ else if (stats.FastestTime == timeTaken && stats.Score < score)
+ {
+ stats.Score = score;
+ }
+
+ challengeStats[challengeId] = stats;
+ }
+ else
+ {
+ // Update the current stage
+ LogChallengeUpdate(steamId, challengeUpdated.Stages[activeStageIndex], currentState);
+ }
+
+ // Send UI update now
+ ClientActionHandler.SendChallengeUpdate(steamId, challengeId, challengeUpdated);
+
+ // If this challenge is finished, mark it to be removed from the active challenges
+ if (currentState.IsFinished())
+ {
+ ChallengesToRemove.Add(new ChallengeEnded() {challengeId = challengeId, steamId = steamId, endStatus = currentState, removeTime = DateTime.Now.AddSeconds(5)});
+ }
+ }
+
+ public static Challenge GetChallenge(string challengeId)
+ {
+ return ChallengeDatabase.Challenges.Find(challenge => challenge.ID == challengeId);
+ }
+
+ public static void ValidateChallenges()
+ {
+ var knownIDs = new HashSet();
+ try
+ {
+ ChallengeDatabase.Challenges = ChallengeDatabase.Challenges.Select(challenge =>
+ {
+ // Make sure IDs are set and are unique
+ if (challenge.ID == "" || knownIDs.Contains(challenge.ID))
+ {
+ challenge.ID = Guid.NewGuid().ToString();
+ }
+
+ knownIDs.Add(challenge.ID);
+
+ return challenge;
+ }).ToList();
+
+ // Remove stats for challenges that no longer exist
+ // Note that removing challenges is in the try so that if there is an issue parsing the challenges, the history is not removed
+ foreach (var (steamId, stats) in PlayerChallengeStats)
+ {
+ foreach (var challengeId in stats.Keys.Where(challengeId => !knownIDs.Contains(challengeId)))
+ {
+ stats.Remove(challengeId);
+ }
+ }
+ }
+ catch
+ {
+ // Challenge validation failed, disabling the system
+ Plugin.ChallengeSystemActive = false;
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Error, "Disabling Challenge system. Configuration validation failed. Check challenge JSON configuration.", true);
+ }
+ }
+
+ private static void LogChallengeUpdate(ulong steamId, Stage stage, State status)
+ {
+ // Only log this if the user is logging
+ if (!IsPlayerLoggingChallenges(steamId)) return;
+
+ // Should not get into here with a status of NotStarted. Everything should be in progress, complete or failed.
+ if (status == State.NotStarted) return;
+
+ L10N.LocalisableString message;
+ switch (status)
+ {
+ case State.InProgress:
+ var averageProgress = stage.CurrentProgress();
+ string progress;
+ if (averageProgress >= 0)
+ {
+ progress = $"{averageProgress:P0}";
+ }
+ else
+ {
+ // TODO also get saving/loading on disk
+ stage.CalculateScore(out _, out var score);
+ progress = $"{score:F0}";
+ }
+ message = L10N.Get(L10N.TemplateKey.ChallengeProgress)
+ .AddField("{stage}", $"{stage.Index + 1:D}")
+ .AddField("{progress}", progress);
+ break;
+ case State.Failed:
+ message = L10N.Get(L10N.TemplateKey.ChallengeFailed);
+ break;
+ case State.Complete:
+ message = L10N.Get(L10N.TemplateKey.ChallengeStageComplete);
+ break;
+ case State.ChallengeComplete:
+ message = L10N.Get(L10N.TemplateKey.ChallengeComplete);
+ break;
+ default:
+ // There is no supported state to report here
+ return;
+ }
+ Output.SendMessage(steamId, message);
+ }
+
+ public static Challenge CreateChallenge(List> stages, string label, bool repeatable)
+ {
+ return new Challenge()
+ {
+ ID = Guid.NewGuid().ToString(),
+ Objectives = stages,
+ Label = label,
+ CanRepeat = repeatable
+ };
+ }
+
+ public static Objective CreateKillObjective(int killCount, int minutes, List factions = null, List bloodTypes = null)
+ {
+ return new Objective()
+ {
+ killCount = killCount,
+ limit = TimeSpan.FromMinutes(minutes),
+ factions = factions,
+ unitBloodType = bloodTypes,
+ };
+ }
+
+ private static void UpdateRemovedChallenges()
+ {
+ var now = DateTime.Now;
+ while (ChallengesToRemove.Count > 0)
+ {
+ var challenge = ChallengesToRemove[0];
+ if (now >= challenge.removeTime)
+ {
+ // Check to see that it hasn't been restarted
+ var activeChallenges = PlayerActiveChallenges[challenge.steamId];
+ if (activeChallenges.TryGetValue(challenge.challengeId, out var state))
+ {
+ var currentState = state.CurrentState();
+ if (!currentState.IsFinished())
+ {
+ // This is not finished yet (likely restarted). Remove it from the remove list
+ ChallengesToRemove.RemoveAt(0);
+ // move along to the next challenge to remove
+ continue;
+ }
+
+ // send remove to UI
+ ClientActionHandler.SendChallengeUpdate(challenge.steamId, challenge.challengeId, state, true);
+ }
+ ChallengesToRemove.RemoveAt(0);
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"Removing: {challenge.steamId} {challenge.challengeId}");
+ }
+ else
+ {
+ // Not ready to remove any more (challenges are ordered by time)
+ break;
+ }
+ }
+ }
+
+ public static ChallengeConfig DefaultBasicChallenges()
+ {
+ return new ChallengeConfig()
+ {
+ Challenges = new List
+ {
+ CreateChallenge(new()
+ {
+ new List() {
+ CreateKillObjective(10, 0, new List() { Faction.Bandits }),
+ CreateKillObjective(10, 0, new List() { Faction.Undead }),
+ CreateKillObjective(10, 0, new List() { Faction.Wolves }) },
+ new List() { CreateKillObjective(1, 0, bloodTypes: new List()
+ {
+ BloodType.VBlood
+ }) }
+ },
+ "Farbane menace",
+ true
+ ),
+ CreateChallenge(new()
+ {
+ new List() { CreateKillObjective(-1, -10, new List() { Faction.Bandits, Faction.Wolves }) },
+ },
+ "Kill bandits in 10m",
+ true
+ ),
+ },
+ ChallengeTemplates = new List()
+ };
+ }
+}
\ No newline at end of file
diff --git a/XPRising/Systems/ExperienceSystem.cs b/XPRising/Systems/ExperienceSystem.cs
index 3eb7e6f..1ac57ca 100644
--- a/XPRising/Systems/ExperienceSystem.cs
+++ b/XPRising/Systems/ExperienceSystem.cs
@@ -128,7 +128,10 @@ private static void AssignExp(Alliance.ClosePlayer player, int calculatedPlayerL
if (player.currentXp >= ConvertLevelToXp(MaxLevel)) return;
var xpGained = CalculateXp(calculatedPlayerLevel, mobLevel, multiplier);
- Helper.CreateXpText(player.triggerPosition, xpGained, player.userComponent.LocalCharacter._Entity, player.userEntity);
+ if (Database.PlayerPreferences[player.steamID].ScrollingCombatText)
+ {
+ Helper.CreateXpText(player.triggerPosition, xpGained, player.userComponent.LocalCharacter._Entity, player.userEntity);
+ }
var newXp = Math.Max(player.currentXp, 0) + xpGained;
SetXp(player.steamID, newXp);
diff --git a/XPRising/Systems/GlobalMasterySystem.cs b/XPRising/Systems/GlobalMasterySystem.cs
index d82dba0..5b96b8d 100644
--- a/XPRising/Systems/GlobalMasterySystem.cs
+++ b/XPRising/Systems/GlobalMasterySystem.cs
@@ -52,17 +52,17 @@ public enum MasteryType
WeaponClaws,
WeaponTwinblades,
Spell,
- BloodNone = Remainders.BloodType_None,
- BloodBrute = Remainders.BloodType_Brute,
- BloodCorruption = Remainders.BloodType_Corruption, // TODO new
- BloodCreature = Remainders.BloodType_Creature,
- BloodDracula = Remainders.BloodType_DraculaTheImmortal,
- BloodDraculin = Remainders.BloodType_Draculin,
- BloodMutant = Remainders.BloodType_Mutant,
- BloodRogue = Remainders.BloodType_Rogue,
- BloodScholar = Remainders.BloodType_Scholar,
- BloodWarrior = Remainders.BloodType_Warrior,
- BloodWorker = Remainders.BloodType_Worker,
+ BloodNone = BloodType.None,
+ BloodBrute = BloodType.Brute,
+ BloodCorruption = BloodType.Corruption, // TODO new
+ BloodCreature = BloodType.Creature,
+ BloodDracula = BloodType.DraculaTheImmortal,
+ BloodDraculin = BloodType.Draculin,
+ BloodMutant = BloodType.Mutant,
+ BloodRogue = BloodType.Rogue,
+ BloodScholar = BloodType.Scholar,
+ BloodWarrior = BloodType.Warrior,
+ BloodWorker = BloodType.Worker,
}
[Flags]
diff --git a/XPRising/Systems/L10N.cs b/XPRising/Systems/L10N.cs
index f61ad7d..9e26886 100644
--- a/XPRising/Systems/L10N.cs
+++ b/XPRising/Systems/L10N.cs
@@ -140,6 +140,7 @@ public enum TemplateKey
XpLost,
XpSet,
BarXp,
+ BarXpMax,
BarWeaponUnarmed,
BarWeaponSpear,
BarWeaponSword,
@@ -169,6 +170,7 @@ public enum TemplateKey
BarBloodWarrior,
BarBloodWorker,
BarBloodCorruption,
+ BloodVBlood,
BarFactionBandits,
BarFactionBlackFangs,
BarFactionCorrupted,
@@ -177,7 +179,18 @@ public enum TemplateKey
BarFactionLegion,
BarFactionMilitia,
BarFactionUndead,
- BarFactionWerewolf
+ BarFactionWerewolf,
+ ChallengeUpdate,
+ ChallengeStageComplete,
+ ChallengeProgress,
+ ChallengeInProgress,
+ ChallengeFailed,
+ ChallengeComplete,
+ ChallengeListHeader,
+ ChallengeNotFound,
+ ChallengeNotRepeatable,
+ ChallengeLeaderboard,
+ ChallengeLeaderboardEmpty,
}
public static void AddLocalisation(TemplateKey key, string language, string localisation)
diff --git a/XPRising/Systems/PermissionSystem.cs b/XPRising/Systems/PermissionSystem.cs
index e887aae..abc137d 100644
--- a/XPRising/Systems/PermissionSystem.cs
+++ b/XPRising/Systems/PermissionSystem.cs
@@ -72,6 +72,10 @@ public static LazyDictionary DefaultCommandPermissions()
{
var permissions = new LazyDictionary()
{
+ {"challenge leaderboard [1]", 0},
+ {"challenge list", 0},
+ {"challenge log", 0},
+ {"challenge toggle [1]", 0},
{"db load", 100},
{"db save", 100},
{"db wipe", 100},
diff --git a/XPRising/Systems/WantedSystem.cs b/XPRising/Systems/WantedSystem.cs
index 73ab60f..ec973d4 100644
--- a/XPRising/Systems/WantedSystem.cs
+++ b/XPRising/Systems/WantedSystem.cs
@@ -17,16 +17,14 @@
namespace XPRising.Systems
{
public static class WantedSystem {
- private static EntityManager entityManager = Plugin.Server.EntityManager;
-
- public static int heat_cooldown = 10;
- public static int ambush_interval = 60;
- public static int ambush_chance = 50;
- public static float ambush_despawn_timer = 300;
- public static int vBloodMultiplier = 20;
+ public static int HeatCooldown = 10;
+ public static int AmbushInterval = 60;
+ public static int AmbushChance = 50;
+ public static float AmbushDespawnTimer = 300;
+ public static int VBloodMultiplier = 20;
public static float RequiredDistanceFromVBlood = 100;
- private static System.Random rand = new();
+ private static readonly System.Random InternalRandom = new();
public static int HeatPercentageLostOnDeath = 100;
private static readonly ConcurrentQueue<(DateTime, Entity)> SpawnedQueue = new();
@@ -48,7 +46,7 @@ public static void AddAmbushingEntity(Entity entity, DateTime time)
private static void CleanAmbushingEntities()
{
- var spawnCutOffTime = DateTime.Now - TimeSpan.FromSeconds(ambush_despawn_timer * 1.1);
+ var spawnCutOffTime = DateTime.Now - TimeSpan.FromSeconds(AmbushDespawnTimer * 1.1);
while (!SpawnedQueue.IsEmpty && SpawnedQueue.TryPeek(out var spawned) && spawned.Item1 < spawnCutOffTime)
{
// If this entity was spawned more than the ambush_despawn_timer ago, then it should have been destroyed by the game.
@@ -75,7 +73,7 @@ public static void PlayerKillEntity(List closeAllies, Enti
}
else
{
- if (!entityManager.TryGetComponentData(victimEntity, out var victimFactionReference))
+ if (!victimEntity.TryGetComponent(out var victimFactionReference))
{
Plugin.Log(LogSystem.Faction, LogLevel.Warning, () => $"Player killed: Entity: {unit}, but it has no faction");
return;
@@ -115,7 +113,7 @@ private static void HandlePlayerKill(Entity userEntity, Faction victimFaction, i
}
// reset the last ambushed time now they have a higher wanted level so that they can be ambushed again
- var newLastAmbushed = DateTime.Now - TimeSpan.FromSeconds(ambush_interval);
+ var newLastAmbushed = DateTime.Now - TimeSpan.FromSeconds(AmbushInterval);
UpdatePlayerHeat(userEntity, victimFaction, heatData.heat[victimFaction].level + heatValue, newLastAmbushed);
}
@@ -126,9 +124,9 @@ private static void HandlePlayerKill(Entity userEntity, Faction victimFaction, i
}
public static void PlayerDied(Entity victimEntity) {
- var player = entityManager.GetComponentData(victimEntity);
+ var player = victimEntity.Read();
var userEntity = player.UserEntity;
- var user = entityManager.GetComponentData(userEntity);
+ var user = userEntity.Read();
var steamID = user.PlatformId;
var preferences = Database.PlayerPreferences[steamID];
@@ -203,7 +201,7 @@ public static void CheckForAmbush(Entity triggeringPlayerEntity) {
TimeSpan timeSinceAmbush = DateTime.Now - heat.lastAmbushed;
var wantedLevel = FactionHeat.GetWantedLevel(heat.level);
- if (timeSinceAmbush.TotalSeconds > ambush_interval && wantedLevel > 0) {
+ if (timeSinceAmbush.TotalSeconds > AmbushInterval && wantedLevel > 0) {
Plugin.Log(LogSystem.Wanted, LogLevel.Info, $"{faction} can ambush");
// If there is no stored wanted level yet, or if this ally's wanted level is higher, then set it.
@@ -224,7 +222,7 @@ public static void CheckForAmbush(Entity triggeringPlayerEntity) {
var ambushingFaction = Faction.Unknown;
var ambushingTime = DateTime.Now;
foreach (var faction in sortedFactionList) {
- if (rand.Next(0, 100) <= ambush_chance) {
+ if (InternalRandom.Next(0, 100) <= AmbushChance) {
FactionHeat.Ambush(triggerLocation.Position, closeAllies, faction.Key, faction.Value);
isAmbushing = true;
ambushingFaction = faction.Key;
@@ -284,8 +282,8 @@ private static PlayerHeatData UpdatePlayerHeat(Entity userEntity, Faction heatFa
var message = newWantedLevel < oldWantedLevel
? L10N.Get(L10N.TemplateKey.WantedHeatDecrease)
: L10N.Get(L10N.TemplateKey.WantedHeatIncrease);
- message.AddField("{factionStatus}", FactionHeat.GetFactionStatus(heatFaction, heat.level));
- var colourIndex = Math.Clamp(newWantedLevel - 1, 0, FactionHeat.ColourGradient.Length - 1);
+ message.AddField("{factionStatus}", FactionHeat.GetFactionStatus(heatFaction, heat.level, steamID));
+ var colourIndex = Math.Clamp(newWantedLevel - 1, 0, FactionHeat.LastHeatIndex);
Output.SendMessage(userEntity, message, $"#{FactionHeat.ColourGradient[colourIndex]}");
}
// Make sure the cooldown timer has started
@@ -325,7 +323,7 @@ public static bool CanSpawn(float3 position)
}
private static void HeatManager(Entity userEntity, out PlayerHeatData heatData, out ulong steamID) {
- steamID = entityManager.GetComponentData(userEntity).PlatformId;
+ steamID = userEntity.Read().PlatformId;
if (!Database.PlayerHeat.TryGetValue(steamID, out heatData)) {
heatData = new PlayerHeatData();
@@ -337,10 +335,11 @@ public static bool CanCooldownHeat(DateTime lastCombatStart, DateTime lastCombat
// There are some edge cases (such as player disconnecting during this period) that can mean the combat end
// was never set correctly. As combat start should be logged about once every 10s, if we are well past this point
// without a new combat start, just consider it ended.
- var inCombat = lastCombatStart >= lastCombatEnd && lastCombatStart + TimeSpan.FromSeconds(15) > DateTime.Now;
- Plugin.Log(LogSystem.Wanted, LogLevel.Info, $"Heat CD period: combat: {inCombat}");
+ var inCombat = lastCombatStart >= lastCombatEnd && lastCombatStart + TimeSpan.FromSeconds(20) > DateTime.Now;
+ var timeOutOfCombat = inCombat ? TimeSpan.Zero : DateTime.Now - lastCombatEnd;
+ Plugin.Log(LogSystem.Wanted, LogLevel.Info, () => "Heat CD period: " + (inCombat ? "in combat" : $"{timeOutOfCombat.TotalSeconds:F1}s out of combat"));
- return !inCombat && (lastCombatEnd + TimeSpan.FromSeconds(20)) < DateTime.Now;
+ return !inCombat && timeOutOfCombat > TimeSpan.FromSeconds(20);
}
private static string HeatDataString(PlayerHeatData heatData, bool useColor) {
diff --git a/XPRising/Systems/WeaponMasterySystem.cs b/XPRising/Systems/WeaponMasterySystem.cs
index 967190d..bb5bea3 100644
--- a/XPRising/Systems/WeaponMasterySystem.cs
+++ b/XPRising/Systems/WeaponMasterySystem.cs
@@ -63,7 +63,7 @@ public static void HandleDamageEvent(Entity sourceEntity, Entity targetEntity, f
public static void UpdateMastery(ulong steamID, MasteryType masteryType, double masteryValue, Entity victimEntity)
{
- var isVBlood = Helper.IsVBlood(victimEntity);
+ var (_, _, isVBlood) = Helper.GetBloodInfo(victimEntity);
var vBloodMultiplier = isVBlood ? VBloodMultiplier : 1;
var changeInMastery = masteryValue * vBloodMultiplier * MasteryGainMultiplier * 0.02;
diff --git a/XPRising/Transport/ClientActionHandler.cs b/XPRising/Transport/ClientActionHandler.cs
index d0be36e..ce5985d 100644
--- a/XPRising/Transport/ClientActionHandler.cs
+++ b/XPRising/Transport/ClientActionHandler.cs
@@ -1,6 +1,9 @@
using BepInEx.Logging;
using ProjectM.Network;
+using XPRising.Commands;
using XPRising.Models;
+using XPRising.Models.Challenges;
+using XPRising.Models.ObjectiveTrackers;
using XPRising.Systems;
using XPRising.Utils;
using XPRising.Utils.Prefabs;
@@ -31,12 +34,15 @@ public static void HandleClientRegistered(ulong steamId)
var user = player.UserEntity.GetUser();
InternalRegisterClient(steamId, user);
- SendUIData(user, true, true);
+ var preferences = Database.PlayerPreferences[user.PlatformId];
+ SendUIData(user, true, true, preferences);
}
private const string BarToggleAction = "XPRising.BarMode";
+ private const string DisplayBuffsAction = "XPRising.DisplayBuffs";
public static void HandleClientAction(User user, ClientAction action)
{
+ var preferences = Database.PlayerPreferences[user.PlatformId];
Plugin.Log(Plugin.LogSystem.Core, LogLevel.Info, $"UI Message: {user.PlatformId}: {action.Action}");
var sendPlayerData = false;
var sendActionData = false;
@@ -55,6 +61,17 @@ public static void HandleClientAction(User user, ClientAction action)
sendPlayerData = true;
sendActionData = true;
break;
+ case DisplayBuffsAction:
+ Cache.SteamPlayerCache.TryGetValue(user.PlatformId, out var playerData);
+ var messages = new List();
+ PlayerInfoCommands.GenerateBuffStatus(playerData, ref messages);
+ var stringMessages = messages.Select(message => message.Build(preferences.Language)).ToList();
+ XPShared.Transport.Utils.ServerSendText(user, "XPRising.BuffText", "XPRising.BuffText", L10N.Get(L10N.TemplateKey.PlayerInfoBuffs).Build(preferences.Language), stringMessages);
+ break;
+ default:
+ sendActionData = true;
+ ChallengeSystem.ToggleChallenge(user.PlatformId, action.Value);
+ break;
}
break;
case ClientAction.ActionType.Disconnect:
@@ -63,21 +80,20 @@ public static void HandleClientAction(User user, ClientAction action)
break;
}
- SendUIData(user, sendPlayerData, sendActionData);
+ SendUIData(user, sendPlayerData, sendActionData, preferences);
}
- public static void SendUIData(User user, bool sendPlayerData, bool sendActionData)
+ public static void SendUIData(User user, bool sendPlayerData, bool sendActionData, PlayerPreferences preferences)
{
// Only send UI data if the player is online and have connected with the UI.
if (!PlayerCache.IsPlayerOnline(user.PlatformId) || !Cache.PlayerClientUICache[user.PlatformId]) return;
- if (sendPlayerData) SendPlayerData(user);
- if (sendActionData) SendActionData(user);
+ if (sendPlayerData) SendPlayerData(user, preferences);
+ if (sendActionData) SendActionData(user, preferences);
}
- private static void SendPlayerData(User user)
+ private static void SendPlayerData(User user, PlayerPreferences preferences)
{
- var preferences = Database.PlayerPreferences[user.PlatformId];
var userUiBarPreference = preferences.UIProgressDisplay;
if (Plugin.ExperienceSystemActive)
@@ -149,6 +165,11 @@ private static void SendPlayerData(User user)
// Send a bar for this group to ensure the UI is in a good state.
SendWantedData(user, Faction.Critters, 0, preferences.Language);
}
+
+ if (Plugin.ChallengeSystemActive)
+ {
+ SendChallengeData(user);
+ }
}
public static void SendActiveBloodMasteryData(User user, GlobalMasterySystem.MasteryType activeBloodType)
@@ -228,14 +249,15 @@ public static void SendXpData(User user, int level, float progressPercent, int e
// Only send UI data to users if they have connected with the UI.
if (!Cache.PlayerClientUICache[user.PlatformId]) return;
var preferences = Database.PlayerPreferences[user.PlatformId];
- var tooltip =
+ var tooltip = level == ExperienceSystem.MaxLevel ? L10N.Get(L10N.TemplateKey.BarXpMax).Build(preferences.Language) :
L10N.Get(L10N.TemplateKey.BarXp)
.AddField("{earned}", $"{earned}")
.AddField("{needed}", $"{needed}")
.Build(preferences.Language);
+ var percentage = level == ExperienceSystem.MaxLevel ? -1f : progressPercent;
var changeText = change == 0 ? "" : $"{change:+##.###;-##.###;0}";
- XPShared.Transport.Utils.ServerSetBarData(user, "XPRising.XP", "XP", $"{level:D2}", progressPercent, tooltip, ActiveState.Active, preferences.XpBarColour, changeText);
+ XPShared.Transport.Utils.ServerSetBarData(user, "XPRising.XP", "XP", $"{level:D2}", percentage, tooltip, ActiveState.Active, preferences.XpBarColour, changeText, change != 0);
}
public static void SendMasteryData(User user, GlobalMasterySystem.MasteryType type, float mastery, float effectiveness, string userLanguage,
@@ -271,25 +293,161 @@ public static void SendWantedData(User user, Faction faction, int heat, string u
if (!Cache.PlayerClientUICache[user.PlatformId]) return;
var heatIndex = FactionHeat.GetWantedLevel(heat);
- var percentage = 1f;
+ var percentage = -1f;
var colourString = "";
var activeState = ActiveState.Active;
- if (heatIndex == FactionHeat.HeatLevels.Length)
+ var label = FactionTooltip(faction, userLanguage);
+
+ var atMaxHeat = heatIndex == FactionHeat.HeatLevels.Length;
+ if (atMaxHeat)
{
- colourString = $"#{FactionHeat.ColourGradient[heatIndex - 1]}";
+ colourString = $"#{FactionHeat.MaxHeatColour}";
+ label = $"{label} (+{heat-FactionHeat.LastHeatThreshold:D})";
}
else
{
- var atMaxHeat = heatIndex == FactionHeat.HeatLevels.Length;
var baseHeat = heatIndex > 0 ? FactionHeat.HeatLevels[heatIndex - 1] : 0;
- percentage = atMaxHeat ? 1 : (float)(heat - baseHeat) / (FactionHeat.HeatLevels[heatIndex] - baseHeat);
+ percentage = (float)(heat - baseHeat) / (FactionHeat.HeatLevels[heatIndex] - baseHeat);
activeState = heat > 0 ? ActiveState.Active : ActiveState.NotActive;
var colour1 = heatIndex > 0 ? $"#{FactionHeat.ColourGradient[heatIndex - 1]}" : "white";
- var colour2 = atMaxHeat ? colour1 : $"#{FactionHeat.ColourGradient[heatIndex]}";
+ var colour2 = $"#{FactionHeat.ColourGradient[heatIndex]}";
colourString = $"@{colour1}@{colour2}";
}
- XPShared.Transport.Utils.ServerSetBarData(user, "XPRising.heat", $"{faction}", $"{heatIndex:D}★", percentage, FactionTooltip(faction, userLanguage), activeState, colourString);
+ XPShared.Transport.Utils.ServerSetBarData(user, "XPRising.heat", $"{faction}", $"{heatIndex:D}★", percentage, label, activeState, colourString);
+ }
+
+ public static void SendChallengeUpdate(ulong steamId, string challengeId, ChallengeState state, bool remove = false)
+ {
+ // Only send UI data to users if they have connected with the UI.
+ if (!Cache.PlayerClientUICache[steamId] || !PlayerCache.FindPlayer(steamId, true, out _, out _, out var user)) return;
+
+ var preferences = Database.PlayerPreferences[steamId];
+
+ // Make this remove any out-of-date bars + set the current bars
+ foreach (var stage in state.Stages)
+ {
+ var status = stage.CurrentState();
+ var percentage = status == State.InProgress ? stage.CurrentProgress() : -1f;
+
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"{user.PlatformId} stage bars: {stage.Index} {status}");
+ InternalSendChallengeBar(user, preferences, MakeBarId(challengeId, stage.Index, 0, true), status, $"S{stage.Index + 1:D}", "", percentage, false, remove);
+ for (var i = 0; i < stage.Objectives.Count; i++)
+ {
+ var objective = stage.Objectives[i];
+ // Only show the objective when the stage is in progress
+ var showObjective = !remove && status == State.InProgress;
+ // Update the progress
+ InternalSendChallengeBar(user, preferences, MakeBarId(challengeId, stage.Index, i, false), objective.Status, $"--", objective.Objective, objective.Progress, true, !showObjective);
+ }
+ }
+
+ // Update the button
+ var challenge = ChallengeSystem.GetChallenge(challengeId);
+ InternalSendChallengeButton(user, preferences, challenge, state.CurrentState());
+ }
+
+ public static void SendChallengeTimerUpdate(ulong steamId, string challengeId, IObjectiveTracker objective)
+ {
+ // Only send UI data to users if they have connected with the UI.
+ if (!Cache.PlayerClientUICache[steamId] || !PlayerCache.FindPlayer(steamId, true, out _, out _, out var user)) return;
+
+ var preferences = Database.PlayerPreferences[steamId];
+
+ // Update the display
+ InternalSendChallengeBar(user, preferences, MakeBarId(challengeId, objective.StageIndex, objective.Index, false), objective.Status, $"--", objective.Objective, objective.Progress, false, false);
+ }
+
+ private static string MakeBarId(string challengeId, int stageIndex, int objectiveIndex, bool isStage)
+ {
+ return isStage ? $"{challengeId}-{stageIndex:D}" : $"{challengeId}-{stageIndex:D}-{objectiveIndex:D}";
+ }
+
+ private static void InternalSendChallengeBar(User user, PlayerPreferences preferences, string barId, State status, string header, string label, float percentage, bool flash, bool remove)
+ {
+ var activeState = ActiveState.Active;
+ var colour = preferences.ChallengeActiveBarColour;
+ L10N.LocalisableString message;
+
+ switch (status)
+ {
+ case State.Complete:
+ message = L10N.Get(L10N.TemplateKey.ChallengeStageComplete);
+ break;
+ case State.ChallengeComplete:
+ message = L10N.Get(L10N.TemplateKey.ChallengeComplete);
+ break;
+ case State.NotStarted:
+ message = L10N.Get(L10N.TemplateKey.ChallengeInProgress);
+ colour = preferences.ChallengeInactiveBarColour;
+ percentage = -1;
+ break;
+ case State.Failed:
+ message = L10N.Get(L10N.TemplateKey.ChallengeFailed);
+ colour = preferences.ChallengeFailedBarColour;
+ break;
+ case State.InProgress:
+ message = L10N.Get(L10N.TemplateKey.ChallengeInProgress);
+ break;
+ default:
+ // Ignore other cases
+ return;
+ }
+
+ // If this is a remove update, set the state to remove
+ activeState = remove ? ActiveState.Remove : activeState;
+ label = label == "" ? message.Build(preferences.Language) : label;
+ XPShared.Transport.Utils.ServerSetBarData(user, "XPRising.challenges", barId, header, percentage, label, activeState, colour, "", flash);
+ }
+
+ private static void InternalSendChallengeButton(User user, PlayerPreferences preferences, ChallengeSystem.Challenge challenge, State status)
+ {
+ var label = challenge.Label;
+ switch (status)
+ {
+ case State.NotStarted:
+ break;
+ case State.InProgress:
+ label = $"*{challenge.Label}*";
+ break;
+ case State.Failed:
+ label = challenge.CanRepeat ? label : $"{challenge.Label}";
+ break;
+ case State.Complete:
+ case State.ChallengeComplete:
+ label = challenge.CanRepeat ? label : $"{challenge.Label} [✓]";
+ break;
+ }
+ XPShared.Transport.Utils.ServerSetAction(user, $"XPRising.challenges", challenge.ID, label);
+ Plugin.Log(Plugin.LogSystem.Challenge, LogLevel.Info, $"{user.PlatformId} buttton: {label}");
+ }
+
+ private static void SendChallengeData(User user)
+ {
+ var preferences = Database.PlayerPreferences[user.PlatformId];
+
+ // Only need the challenge data if we are using the challenge system
+ if (Plugin.ChallengeSystemActive)
+ {
+ foreach (var (challenge, status) in ChallengeSystem.ListChallenges(user.PlatformId))
+ {
+ InternalSendChallengeButton(user, preferences, challenge, status);
+ }
+ }
+ }
+
+ private static void SendChallengeActions(User user)
+ {
+ var preferences = Database.PlayerPreferences[user.PlatformId];
+
+ // Only need the challenge actions if we are using the challenge system
+ if (Plugin.ChallengeSystemActive)
+ {
+ foreach (var (challenge, status) in ChallengeSystem.ListChallenges(user.PlatformId))
+ {
+ InternalSendChallengeButton(user, preferences, challenge, status);
+ }
+ }
}
private static readonly Dictionary FrameTimers = new();
@@ -309,8 +467,9 @@ public static void SendPlayerDataOnDelay(User user)
var newTimer = new FrameTimer();
newTimer.Initialise(() =>
{
+ var preferences = Database.PlayerPreferences[user.PlatformId];
// Update the UI
- SendPlayerData(user);
+ SendPlayerData(user, preferences);
// Remove the timer and dispose of it
if (FrameTimers.Remove(user.PlatformId, out timer)) timer.Stop();
}, TimeSpan.FromMilliseconds(200), 1).Start();
@@ -319,15 +478,13 @@ public static void SendPlayerDataOnDelay(User user)
}
}
- private static void SendActionData(User user)
+ private static void SendActionData(User user, PlayerPreferences preferences)
{
- var userUiBarPreference = Database.PlayerPreferences[user.PlatformId].UIProgressDisplay;
-
// Only need the mastery toggle switch if we are using a mastery mode
if (Plugin.BloodlineSystemActive || Plugin.WeaponMasterySystemActive)
{
string currentMode;
- switch (userUiBarPreference)
+ switch (preferences.UIProgressDisplay)
{
case Actions.BarState.None:
default:
@@ -344,5 +501,13 @@ private static void SendActionData(User user)
XPShared.Transport.Utils.ServerSetAction(user, "XPRising.action", BarToggleAction,
$"Toggle mastery [{currentMode}]");
}
+
+ if (Plugin.ShouldApplyBuffs)
+ {
+ XPShared.Transport.Utils.ServerSetAction(user, "XPRising.action", DisplayBuffsAction,
+ $"Show buffs");
+ }
+
+ SendChallengeActions(user);
}
}
\ No newline at end of file
diff --git a/XPRising/Utils/AutoSaveSystem.cs b/XPRising/Utils/AutoSaveSystem.cs
index 58da920..10e9597 100644
--- a/XPRising/Utils/AutoSaveSystem.cs
+++ b/XPRising/Utils/AutoSaveSystem.cs
@@ -20,6 +20,7 @@ public static class AutoSaveSystem
public static string ConfigPath => Path.Combine(BasePath, ConfigFolder);
public static string SavesPath => Path.Combine(BasePath, ConfigFolder, "Data");
public static string BackupsPath => Path.Combine(BasePath, ConfigFolder, SavesPath, "Backup");
+ public static bool JsonConfigPrettyPrinted = true;
private static Regex _folderValidation = new Regex(@"([^\w]+)");
@@ -37,6 +38,8 @@ public static class AutoSaveSystem
private const string GlobalMasteryConfigJson = "globalMasteryConfig.json";
private const string PlayerPreferencesJson = "playerPreferences.json";
private const string PlayerWantedLevelJson = "playerWantedLevel.json";
+ private const string ChallengesJson = "challenges.json";
+ private const string ChallengeStatsJson = "challengeStats.json";
private static DateTime _timeSinceLastAutoSave = DateTime.Now;
private static DateTime _timeSinceLastBackupSave = DateTime.Now;
@@ -69,6 +72,7 @@ public static class AutoSaveSystem
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)
}
};
+ private static JsonSerializerOptions _jsonOptions => JsonConfigPrettyPrinted ? PrettyJsonOptions : JsonOptions;
public static string NormaliseConfigFolder(string serverName)
{
@@ -122,28 +126,34 @@ private static bool InternalSaveDatabase(string saveFolder)
// Core
anyErrors |= !SaveDB(saveFolder, CommandPermissionJson, Database.CommandPermission, PrettyJsonOptions);
anyErrors |= !SaveDB(saveFolder, UserPermissionJson, Database.UserPermission, PrettyJsonOptions);
- anyErrors |= !SaveDB(saveFolder, PlayerPreferencesJson, Database.PlayerPreferences, JsonOptions);
- anyErrors |= !SaveDB(saveFolder, PlayerLogoutJson, Database.PlayerLogout, JsonOptions);
+ anyErrors |= !SaveDB(saveFolder, PlayerPreferencesJson, Database.PlayerPreferences, _jsonOptions);
+ anyErrors |= !SaveDB(saveFolder, PlayerLogoutJson, Database.PlayerLogout, _jsonOptions);
- if (Plugin.WaypointsActive) anyErrors |= !SaveDB(saveFolder, WaypointsJson, Database.Waypoints, JsonOptions);
- if (Plugin.PowerUpCommandsActive) anyErrors |= !SaveDB(saveFolder, PowerUpJson, Database.PowerUpList, JsonOptions);
+ if (Plugin.WaypointsActive) anyErrors |= !SaveDB(saveFolder, WaypointsJson, Database.Waypoints, _jsonOptions);
+ if (Plugin.PowerUpCommandsActive) anyErrors |= !SaveDB(saveFolder, PowerUpJson, Database.PowerUpList, _jsonOptions);
if (Plugin.ExperienceSystemActive)
{
- anyErrors |= !SaveDB(saveFolder, PlayerExperienceJson, Database.PlayerExperience, JsonOptions);
- anyErrors |= !SaveDB(saveFolder, PlayerAbilityPointsJson, Database.PlayerAbilityIncrease, JsonOptions);
- anyErrors |= !SaveDB(saveFolder, PlayerLevelStatsJson, Database.PlayerLevelStats, JsonOptions);
+ anyErrors |= !SaveDB(saveFolder, PlayerExperienceJson, Database.PlayerExperience, _jsonOptions);
+ anyErrors |= !SaveDB(saveFolder, PlayerAbilityPointsJson, Database.PlayerAbilityIncrease, _jsonOptions);
+ anyErrors |= !SaveDB(saveFolder, PlayerLevelStatsJson, Database.PlayerLevelStats, _jsonOptions);
anyErrors |= !SaveDB(saveFolder, ExperienceClassStatsJson, Database.ExperienceClassStats, PrettyJsonOptions);
}
if (Plugin.WantedSystemActive)
{
- anyErrors |= !SaveDB(saveFolder, PlayerWantedLevelJson, Database.PlayerHeat, JsonOptions);
+ anyErrors |= !SaveDB(saveFolder, PlayerWantedLevelJson, Database.PlayerHeat, _jsonOptions);
}
if (Plugin.WeaponMasterySystemActive || Plugin.BloodlineSystemActive)
{
- anyErrors |= !SaveDB(saveFolder, PlayerMasteryJson, Database.PlayerMastery, JsonOptions);
+ anyErrors |= !SaveDB(saveFolder, PlayerMasteryJson, Database.PlayerMastery, _jsonOptions);
+ }
+
+ if (Plugin.ChallengeSystemActive)
+ {
+ anyErrors |= !SaveDB(saveFolder, ChallengesJson, ChallengeSystem.ChallengeDatabase, _jsonOptions);
+ anyErrors |= !SaveDB(saveFolder, ChallengeStatsJson, ChallengeSystem.PlayerChallengeStats, _jsonOptions);
}
Plugin.Log(LogSystem.Core, LogLevel.Info, $"All databases saved to: {saveFolder}");
@@ -227,6 +237,13 @@ private static bool InternalLoadDatabase(bool useInitialiser, LoadMethod loadMet
// Load the config (or the default config) into the system.
GlobalMasterySystem.SetMasteryConfig(config);
}
+
+ if (Plugin.ChallengeSystemActive)
+ {
+ ConfirmFile(SavesPath, ChallengesJson, () => JsonSerializer.Serialize(ChallengeSystem.DefaultBasicChallenges(), PrettyJsonOptions));
+ anyErrors |= !LoadDB(ChallengesJson, loadMethod, useInitialiser, ref ChallengeSystem.ChallengeDatabase);
+ anyErrors |= !LoadDB(ChallengeStatsJson, loadMethod, useInitialiser, ref ChallengeSystem.PlayerChallengeStats);
+ }
Plugin.Log(LogSystem.Core, LogLevel.Info, "All database data is now loaded.", true);
return !anyErrors;
@@ -292,7 +309,7 @@ private static bool SaveDB(string saveFolder, string specificFile, TData
try {
var saveFile = ConfirmFile(folder, specificFile, () => defaultContents);
var jsonString = File.ReadAllText(saveFile);
- data = JsonSerializer.Deserialize(jsonString, JsonOptions);
+ data = JsonSerializer.Deserialize(jsonString, _jsonOptions);
Plugin.Log(LogSystem.Core, LogLevel.Info, $"DB loaded from {specificFile}");
// return false if the saved file only contains the default contents. This allows the default constructors to run.
return !defaultContents.Equals(jsonString);
diff --git a/XPRising/Utils/FactionHeat.cs b/XPRising/Utils/FactionHeat.cs
index 22ac95d..5f87bd8 100644
--- a/XPRising/Utils/FactionHeat.cs
+++ b/XPRising/Utils/FactionHeat.cs
@@ -1,19 +1,16 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using BepInEx.Logging;
+using BepInEx.Logging;
using ProjectM.Network;
+using Stunlock.Core;
using Unity.Entities;
using Unity.Mathematics;
using XPRising.Systems;
+using XPRising.Transport;
using XPRising.Utils.Prefabs;
using Faction = XPRising.Utils.Prefabs.Faction;
using LogSystem = XPRising.Plugin.LogSystem;
namespace XPRising.Utils;
-using Faction = Prefabs.Faction;
-
public static class FactionHeat {
public static readonly Faction[] ActiveFactions = {
Faction.Bandits,
@@ -28,82 +25,67 @@ public static class FactionHeat {
public static readonly string[] ColourGradient = { "fef001", "ffce03", "fd9a01", "fd6104", "ff2c05", "f00505" };
- public static readonly int[] HeatLevels = { 150, 250, 500, 1000, 1500, 3000 };
+ public static readonly int[] HeatLevels = { 100, 250, 500, 1000, 1500, 3000 };
+ public static readonly int LastHeatIndex = HeatLevels.Length - 1;
+ public static readonly int LastHeatThreshold = HeatLevels[LastHeatIndex];
+ public static readonly string MaxHeatColour = ColourGradient[LastHeatIndex];
// Units that generate extra heat.
private static readonly HashSet ExtraHeatUnits = new HashSet(
FactionUnits.farmNonHostile.Select(u => u.type)
.Union(FactionUnits.farmFood.Select(u => u.type))
.Union(FactionUnits.otherNonHostile.Select(u => u.type)));
+
+ public static void GetActiveFaction(PrefabGUID guid, out Faction activeFaction)
+ {
+ var faction = Helper.ConvertGuidToFaction(guid);
+ GetActiveFaction(faction, out activeFaction);
+ }
- public static void GetActiveFactionHeatValue(Faction faction, Units victim, bool isVBlood, out int heatValue, out Faction activeFaction) {
+ public static void GetActiveFaction(Faction faction, out Faction activeFaction) {
switch (faction) {
// Bandit
case Faction.Traders_T01:
- heatValue = 300; // Don't kill the merchants
- activeFaction = Faction.Bandits;
- break;
case Faction.Bandits:
- heatValue = 10;
activeFaction = Faction.Bandits;
break;
// Black fangs
case Faction.Blackfangs:
case Faction.Blackfangs_Livith:
- heatValue = 10;
activeFaction = Faction.Blackfangs;
break;
// Human
case Faction.Militia:
- heatValue = 10;
- activeFaction = Faction.Militia;
- break;
case Faction.ChurchOfLum_SpotShapeshiftVampire:
- heatValue = 25;
- activeFaction = Faction.Militia;
- break;
- case Faction.Traders_T02:
- heatValue = 300; // Don't kill the merchants
- activeFaction = Faction.Militia;
- break;
case Faction.ChurchOfLum:
- heatValue = 15;
- activeFaction = Faction.Militia;
- break;
+ case Faction.Traders_T02:
case Faction.World_Prisoners:
- heatValue = 10;
activeFaction = Faction.Militia;
break;
// Human: gloomrot
case Faction.Gloomrot:
- heatValue = 10;
activeFaction = Faction.Gloomrot;
break;
// Legion
case Faction.Legion:
- heatValue = 10;
activeFaction = Faction.Legion;
break;
// Nature
case Faction.Bear:
case Faction.Critters:
case Faction.Wolves:
- heatValue = 10;
activeFaction = Faction.Critters;
break;
// Undead
case Faction.Undead:
- heatValue = 5;
activeFaction = Faction.Undead;
break;
// Werewolves
case Faction.Werewolf:
case Faction.WerewolfHuman:
- heatValue = 20;
activeFaction = Faction.Werewolf;
break;
case Faction.VampireHunters:
- heatValue = 3;
activeFaction = Faction.VampireHunters;
break;
// Do nothing
@@ -126,23 +108,72 @@ public static void GetActiveFactionHeatValue(Faction faction, Units victim, bool
case Faction.Spiders_Shapeshifted:
case Faction.Unknown:
case Faction.Wendigo:
- heatValue = 0;
activeFaction = Faction.Unknown;
break;
default:
- Plugin.Log(Plugin.LogSystem.Wanted, LogLevel.Warning, $"Faction not handled for GetActiveFactionHeatValue: {Enum.GetName(faction)}");
- heatValue = 0;
+ Plugin.Log(LogSystem.Wanted, LogLevel.Warning, $"Faction not handled for GetActiveFaction: {Enum.GetName(faction)}");
activeFaction = Faction.Unknown;
break;
}
+ }
+
+ public static void GetActiveFactionHeatValue(Faction faction, Units victim, bool isVBlood, out int heatValue, out Faction activeFaction)
+ {
+ GetActiveFaction(faction, out activeFaction);
+ if (activeFaction == Faction.Unknown)
+ {
+ heatValue = 0;
+ return;
+ }
+
+ // Default to 10 heat
+ heatValue = 10;
- if (isVBlood) heatValue *= WantedSystem.vBloodMultiplier;
+ // Add in special cases for specific origin factions
+ switch (faction) {
+ // Bandit
+ case Faction.Traders_T01:
+ heatValue = 300; // Don't kill the merchants
+ break;
+ // Human
+ case Faction.ChurchOfLum_SpotShapeshiftVampire:
+ heatValue = 25; // These are looking out for vampires - slightly strong so get more heat
+ break;
+ case Faction.Traders_T02:
+ heatValue = 300; // Don't kill the merchants
+ break;
+ case Faction.ChurchOfLum:
+ heatValue = 15; // These are slightly stronger than other militia
+ break;
+ // Legion
+ case Faction.Legion:
+ heatValue = 10;
+ break;
+ // Undead
+ case Faction.Undead:
+ heatValue = 5; // There are generally lots of undead (also skeletons have less agency than "alive" mobs)
+ break;
+ // Werewolves
+ case Faction.Werewolf:
+ case Faction.WerewolfHuman:
+ heatValue = 20; // Fairly individual + more close-knit faction
+ break;
+ case Faction.VampireHunters:
+ heatValue = 3;
+ break;
+ }
+
+ if (isVBlood) heatValue *= WantedSystem.VBloodMultiplier;
else if (ExtraHeatUnits.Contains(victim)) heatValue = (int)(heatValue * 1.5);
}
- public static string GetFactionStatus(Faction faction, int heat) {
- var output = $"{Enum.GetName(faction)}: ";
- return HeatLevels.Aggregate(output, (current, t) => current + (heat < t ? "☆" : "★"));
+ public static string GetFactionStatus(Faction faction, int heat, ulong steamId) {
+ var preferences = Database.PlayerPreferences[steamId];
+ var factionName = ClientActionHandler.FactionTooltip(faction, preferences.Language);
+ var starOutput = HeatLevels.Aggregate("", (current, t) => current + (heat < t ? "☆" : "★"));
+ var additionalHeat = heat > HeatLevels[LastHeatIndex] ? $" (+{heat - LastHeatThreshold})" : "";
+
+ return $"{factionName}: {starOutput}{additionalHeat}";
}
public static int GetWantedLevel(int heat) {
diff --git a/XPRising/Utils/Helper.cs b/XPRising/Utils/Helper.cs
index 88f93c2..53c7c3d 100644
--- a/XPRising/Utils/Helper.cs
+++ b/XPRising/Utils/Helper.cs
@@ -217,17 +217,33 @@ public static bool IsInCastle(Entity user)
return false;
}
- public static bool IsVBlood(Entity entity)
+ public static BloodType GetBloodType(PrefabGUID guid)
{
- return Plugin.Server.EntityManager.TryGetComponentData(entity, out BloodConsumeSource victimBlood) && IsVBlood(victimBlood);
+ return Enum.IsDefined(typeof(BloodType), guid.GuidHash)
+ ? (BloodType)guid.GuidHash
+ : BloodType.Unknown;
}
- public static bool IsVBlood(BloodConsumeSource bloodSource)
+ public static (BloodType, float, bool) GetBloodInfo(Entity entity)
{
- var guidHash = bloodSource.UnitBloodType._Value.GuidHash;
- return guidHash == (int)Remainders.BloodType_VBlood ||
- guidHash == (int)Remainders.BloodType_GateBoss ||
- guidHash == (int)Remainders.BloodType_DraculaTheImmortal;
+ if (entity.TryGetComponent(out var victimBlood))
+ {
+ var bloodType = GetBloodType(victimBlood.UnitBloodType._Value);
+ return (bloodType, victimBlood.BloodQuality, IsVBlood(bloodType));
+ } else if (entity.TryGetComponent(out var killerBlood))
+ {
+ var bloodType = GetBloodType(killerBlood.BloodType);
+ return (bloodType, killerBlood.Quality, IsVBlood(bloodType));
+ }
+
+ return (BloodType.Unknown, 0, false);
+ }
+
+ public static bool IsVBlood(BloodType type)
+ {
+ return type == BloodType.VBlood ||
+ type == BloodType.GateBoss ||
+ type == BloodType.DraculaTheImmortal;
}
public static LazyDictionary GetAllStatBonuses(ulong steamID, Entity owner)
diff --git a/XPRising/Utils/PlayerCache.cs b/XPRising/Utils/PlayerCache.cs
index d2d571c..5aa4513 100644
--- a/XPRising/Utils/PlayerCache.cs
+++ b/XPRising/Utils/PlayerCache.cs
@@ -59,7 +59,8 @@ public static void PlayerOnline(Entity userEntity, User userData)
// Ensure the UI is set up now that they have connected properly.
// Note: Client may not have sent "Connect" packet to server yet.
- ClientActionHandler.SendUIData(userData, true, true);
+ var preferences = Database.PlayerPreferences[playerData.SteamID];
+ ClientActionHandler.SendUIData(userData, true, true, preferences);
}
public static void PlayerOffline(ulong steamID)
diff --git a/XPRising/Utils/Prefabs/BloodType.cs b/XPRising/Utils/Prefabs/BloodType.cs
new file mode 100644
index 0000000..290fdb9
--- /dev/null
+++ b/XPRising/Utils/Prefabs/BloodType.cs
@@ -0,0 +1,21 @@
+namespace XPRising.Utils.Prefabs;
+
+// Note that these prefabs are as of 1.1
+// Thanks https://github.com/Odjit
+public enum BloodType
+{
+ Brute = 804798592,
+ Corruption = -1382693416,
+ Creature = 524822543,
+ DraculaTheImmortal = 2010023718,
+ Draculin = 1328126535,
+ GateBoss = 910644396,
+ Mutant = 1821108694,
+ None = 447918373,
+ Rogue = -1620185637,
+ Scholar = 1476452791,
+ VBlood = -338774148,
+ Warrior = -516976528,
+ Worker = -1776904174,
+ Unknown = 0,
+}
\ No newline at end of file
diff --git a/XPRising/Utils/Prefabs/Buffs.cs b/XPRising/Utils/Prefabs/Buffs.cs
index 92e7661..dbface9 100644
--- a/XPRising/Utils/Prefabs/Buffs.cs
+++ b/XPRising/Utils/Prefabs/Buffs.cs
@@ -1,6 +1,6 @@
namespace XPRising.Utils.Prefabs;
-// Note that these prefabs are as of 1.0 launch
+// Note that these prefabs are as of 1.1
// Thanks https://github.com/Odjit
public enum Buffs
{
diff --git a/XPRising/Utils/Prefabs/Effects.cs b/XPRising/Utils/Prefabs/Effects.cs
index f0d5076..d1a42ba 100644
--- a/XPRising/Utils/Prefabs/Effects.cs
+++ b/XPRising/Utils/Prefabs/Effects.cs
@@ -1,6 +1,6 @@
namespace XPRising.Utils.Prefabs;
-// Note that these prefabs are as of 1.0 launch
+// Note that these prefabs are as of 1.1
// Thanks https://github.com/Odjit
public enum Effects
{
diff --git a/XPRising/Utils/Prefabs/EquipBuff.cs b/XPRising/Utils/Prefabs/EquipBuff.cs
index 719c4e2..842a50e 100644
--- a/XPRising/Utils/Prefabs/EquipBuff.cs
+++ b/XPRising/Utils/Prefabs/EquipBuff.cs
@@ -1,6 +1,6 @@
namespace XPRising.Utils.Prefabs;
-// Note that these prefabs are as of 1.0 launch
+// Note that these prefabs are as of 1.1
// Thanks https://github.com/Odjit
public enum EquipBuffs
{
diff --git a/XPRising/Utils/Prefabs/Factions.cs b/XPRising/Utils/Prefabs/Factions.cs
index 27b5fa8..311437c 100644
--- a/XPRising/Utils/Prefabs/Factions.cs
+++ b/XPRising/Utils/Prefabs/Factions.cs
@@ -1,6 +1,6 @@
namespace XPRising.Utils.Prefabs;
-// Note that these prefabs are as of 1.0 launch
+// Note that these prefabs are as of 1.1
// Thanks https://github.com/Odjit
public enum Faction
{
diff --git a/XPRising/Utils/Prefabs/Items.cs b/XPRising/Utils/Prefabs/Items.cs
index 1b410f1..3e43f62 100644
--- a/XPRising/Utils/Prefabs/Items.cs
+++ b/XPRising/Utils/Prefabs/Items.cs
@@ -1,6 +1,6 @@
namespace XPRising.Utils.Prefabs;
-// Note that these prefabs are as of 1.0 launch
+// Note that these prefabs are as of 1.1
// Thanks https://github.com/Odjit
public enum Items
{
diff --git a/XPRising/Utils/Prefabs/Remainders.cs b/XPRising/Utils/Prefabs/Remainders.cs
index 1f78b28..c0661b3 100644
--- a/XPRising/Utils/Prefabs/Remainders.cs
+++ b/XPRising/Utils/Prefabs/Remainders.cs
@@ -1,22 +1,9 @@
namespace XPRising.Utils.Prefabs;
-// Note that these prefabs are as of 1.0 launch
+// Note that these prefabs are as of 1.1
// Thanks https://github.com/Odjit
public enum Remainders
{
- BloodType_Brute = 804798592,
- BloodType_Corruption = -1382693416,
- BloodType_Creature = 524822543,
- BloodType_DraculaTheImmortal = 2010023718,
- BloodType_Draculin = 1328126535,
- BloodType_GateBoss = 910644396,
- BloodType_Mutant = 1821108694,
- BloodType_None = 447918373,
- BloodType_Rogue = -1620185637,
- BloodType_Scholar = 1476452791,
- BloodType_VBlood = -338774148,
- BloodType_Warrior = -516976528,
- BloodType_Worker = -1776904174,
CritterChar_Bat = 1735423645,
CritterChar_Bunny = 1242469640,
CritterChar_Cat = -2118320949,
diff --git a/XPRising/Utils/Prefabs/Units.cs b/XPRising/Utils/Prefabs/Units.cs
index a605dd4..2784adc 100644
--- a/XPRising/Utils/Prefabs/Units.cs
+++ b/XPRising/Utils/Prefabs/Units.cs
@@ -1,7 +1,7 @@
// ReSharper disable InconsistentNaming
namespace XPRising.Utils.Prefabs;
-// Note that these prefabs are as of 1.0 launch
+// Note that these prefabs are as of 1.1
// Thanks https://github.com/Odjit
public enum Units
{
diff --git a/XPRising/Utils/SquadList.cs b/XPRising/Utils/SquadList.cs
index 153b3b0..01d111b 100644
--- a/XPRising/Utils/SquadList.cs
+++ b/XPRising/Utils/SquadList.cs
@@ -259,7 +259,7 @@ public static string SpawnSquad(int playerLevel, float3 position, Faction factio
wantedLevel > 1 ? playerLevel + wantedLevel - 1:
playerLevel - 2;
var spawnFaction = faction == Faction.Legion ? SpawnUnit.SpawnFaction.WantedUnit : SpawnUnit.SpawnFaction.VampireHunters;
- var lifetime = SpawnUnit.EncodeLifetime((int)WantedSystem.ambush_despawn_timer, unitLevel, spawnFaction);
+ var lifetime = SpawnUnit.EncodeLifetime((int)WantedSystem.AmbushDespawnTimer, unitLevel, spawnFaction);
SpawnUnit.Spawn(unit.type, position, unit.count, unit.range, unit.range + 4f, lifetime);
Plugin.Log(Plugin.LogSystem.SquadSpawn, LogLevel.Info, $"Spawning: {unit.count}*{unit.type}");
}
diff --git a/XPRising/XPRising.csproj b/XPRising/XPRising.csproj
index a6981c9..c8d5890 100644
--- a/XPRising/XPRising.csproj
+++ b/XPRising/XPRising.csproj
@@ -18,8 +18,8 @@
-
-
+
+
diff --git a/XPRising/thunderstore.toml b/XPRising/thunderstore.toml
index 898898e..bbb5eb5 100644
--- a/XPRising/thunderstore.toml
+++ b/XPRising/thunderstore.toml
@@ -10,8 +10,8 @@ websiteUrl = "https://github.com/aontas/XPRising"
containsNsfwContent = false
[package.dependencies]
-BepInEx-BepInExPack_V_Rising = "1.691.3"
-deca-VampireCommandFramework = "0.9.0"
+BepInEx-BepInExPack_V_Rising = "1.733.2"
+deca-VampireCommandFramework = "0.10.3"
XPRising-XPShared = "__VERSION__"
[build]
diff --git a/XPShared/Events/ServerEvents.cs b/XPShared/Events/ServerEvents.cs
new file mode 100644
index 0000000..61eee32
--- /dev/null
+++ b/XPShared/Events/ServerEvents.cs
@@ -0,0 +1,98 @@
+using HarmonyLib;
+using ProjectM;
+using Stunlock.Core;
+using Unity.Collections;
+
+namespace XPShared.Events;
+
+public static class ServerEvents
+{
+#nullable enable
+ public static class BuffEvents
+ {
+ public class BuffGained : VEvents.IGameEvent
+ {
+ public PrefabGUID GUID { get; set; }
+ }
+
+ public class BuffLost : VEvents.IGameEvent
+ {
+ public PrefabGUID GUID { get; set; }
+ }
+ }
+
+ public static class CombatEvents
+ {
+ public class CombatStart : VEvents.IGameEvent
+ {
+ public ulong SteamId { get; set; }
+ }
+
+ public class CombatEnd : VEvents.IGameEvent
+ {
+ public ulong SteamId { get; set; }
+ }
+
+ public class PlayerKillMob : VEvents.DynamicGameEvent
+ {
+ }
+
+ public class PlayerKillModule : VEvents.GameEvent
+ {
+ private static PlayerKillModule? _instance;
+ static Harmony? _harmony;
+ public override void Initialize()
+ {
+ _harmony = Harmony.CreateAndPatchAll(typeof(Patch), MyPluginInfo.PLUGIN_GUID);
+ }
+ public override void Uninitialize() => _harmony?.UnpatchSelf();
+ public PlayerKillModule()
+ {
+ _instance = this;
+ VEvents.ModuleRegistry.Register(_instance);
+ }
+ public class Patch {
+ [HarmonyPatch(typeof(DeathEventListenerSystem), nameof(DeathEventListenerSystem.OnUpdate))]
+ public static void Postfix(DeathEventListenerSystem __instance)
+ {
+ // If we have no subscribers don't worry about running a query
+ if (_instance == null || !_instance.HasSubscribers) return;
+
+ NativeArray deathEvents = __instance._DeathEventQuery.ToComponentDataArray(Allocator.Temp);
+ foreach (DeathEvent ev in deathEvents) {
+ // DebugTool.LogEntity(ev.Died, "Death Event occured for:", LogSystem.Death);
+ // TODO check the following for Bloodcraft minor XP
+ //if (entity.TryGetComponent(out IsMinion isMinion) && isMinion.Value)
+
+ var killer = ev.Killer;
+
+ // For this to count as a player kill, it must:
+ // - not be a minion
+ // - have a level
+ // - have a movement object
+ var ignoreAsKill = __instance.EntityManager.HasComponent(ev.Died) || !__instance.EntityManager.HasComponent(ev.Died) || !__instance.EntityManager.HasComponent(ev.Died);
+
+ // If the killer is the victim, then we can skip trying to raise this event.
+ if (!ignoreAsKill && !killer.Equals(ev.Died))
+ {
+ // If the entity killing is a minion, switch the killer to the owner of the minion.
+ if (__instance.EntityManager.HasComponent(killer))
+ {
+ if (__instance.EntityManager.TryGetComponentData(killer, out var entityOwner))
+ {
+ killer = entityOwner.Owner;
+ }
+ }
+
+ if (__instance.EntityManager.HasComponent(killer))
+ {
+ _instance?.Raise(new PlayerKillMob {Source = killer, Target = ev.Died});
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+#nullable restore
+}
\ No newline at end of file
diff --git a/XPShared/Events/VEvents.cs b/XPShared/Events/VEvents.cs
new file mode 100644
index 0000000..fc5c8c9
--- /dev/null
+++ b/XPShared/Events/VEvents.cs
@@ -0,0 +1,96 @@
+using BepInEx.Logging;
+using Unity.Entities;
+
+namespace XPShared.Events;
+
+// Concepts taking from @mfoltz updates for Bloodstone. Preparing standardisation support.
+public class VEvents
+{
+#nullable enable
+ public interface IGameEvent { }
+ public abstract class DynamicGameEvent : EventArgs, IGameEvent
+ {
+ public Entity Source { get; set; }
+ public Entity? Target { get; set; }
+
+ readonly Dictionary _components = [];
+ public void AddComponent(T component) where T : struct => _components[typeof(T)] = component;
+ public bool TryGetComponent(out T component) where T : struct
+ {
+ if (_components.TryGetValue(typeof(T), out var boxed) && boxed is T cast)
+ {
+ component = cast;
+ return true;
+ }
+
+ component = default;
+ return false;
+ }
+ }
+ public abstract class GameEvent where T : IGameEvent, new()
+ {
+ public delegate void EventModuleHandler(T args);
+ public event EventModuleHandler? EventHandler;
+ public bool HasSubscribers => EventHandler != null;
+ protected void Raise(T args)
+ {
+ EventHandler?.Invoke(args);
+ }
+ public void Subscribe(EventModuleHandler handler) => EventHandler += handler;
+
+ public void Unsubscribe(EventModuleHandler handler) => EventHandler -= handler;
+ public abstract void Initialize();
+ public abstract void Uninitialize();
+ }
+ public static class ModuleRegistry
+ {
+ static readonly Dictionary _modules = [];
+ public static void Register(GameEvent module) where T : IGameEvent, new()
+ {
+ module.Initialize();
+ _modules[typeof(T)] = module;
+ }
+ public static void Subscribe(Action handler) where T : IGameEvent, new()
+ {
+ if (_modules.TryGetValue(typeof(T), out var module))
+ {
+ ((GameEvent)module).Subscribe(handler.Invoke);
+ }
+ else
+ {
+ Plugin.Log(LogLevel.Warning, $"[Subscribe] No registered module for event type! ({typeof(T).Name})");
+ }
+ }
+ public static void Unsubscribe(Action handler) where T : IGameEvent, new()
+ {
+ if (_modules.TryGetValue(typeof(T), out var module))
+ {
+ ((GameEvent)module).Unsubscribe(handler.Invoke);
+ }
+ else
+ {
+ Plugin.Log(LogLevel.Warning, $"[Unsubscribe] No registered module for event type! ({typeof(T).Name})");
+ }
+ }
+ public static bool TryGet(out GameEvent? module) where T : IGameEvent, new()
+ {
+ if (_modules.TryGetValue(typeof(T), out var result))
+ {
+ Plugin.Log(LogLevel.Warning, $"try get counts: {_modules.Count})");
+ module = (GameEvent)result;
+ return true;
+ }
+
+ module = default;
+ return false;
+ }
+ }
+
+ static bool _initialized = false;
+ public static void Initialize()
+ {
+ if (_initialized) return;
+ _initialized = true;
+ }
+#nullable restore
+}
\ No newline at end of file
diff --git a/XPShared/Plugin.cs b/XPShared/Plugin.cs
index ba53afb..edf812f 100644
--- a/XPShared/Plugin.cs
+++ b/XPShared/Plugin.cs
@@ -6,6 +6,7 @@
using ProjectM.Scripting;
using Unity.Entities;
using UnityEngine;
+using XPShared.Events;
using XPShared.Hooks;
using XPShared.Services;
@@ -35,6 +36,13 @@ public override void Load()
// Ensure the logger is accessible in static contexts.
_logger = base.Log;
+ var assemblyConfigurationAttribute = typeof(Plugin).Assembly.GetCustomAttribute();
+ var buildConfigurationName = assemblyConfigurationAttribute?.Configuration;
+ IsDebug = buildConfigurationName == "Debug";
+
+ // Initialse the VEvents framework so that we can add register more events
+ VEvents.Initialize();
+
if (IsClient)
{
ClientChatPatch.Initialize();
@@ -43,13 +51,12 @@ public override void Load()
{
ServerChatPatch.Initialize();
ChatService.ListenForClientRegister();
+
+ // Add new server event generators
+ _ = new ServerEvents.CombatEvents.PlayerKillModule();
}
_harmonyBootPatch = Harmony.CreateAndPatchAll(typeof(GameManangerPatch));
- var assemblyConfigurationAttribute = typeof(Plugin).Assembly.GetCustomAttribute();
- var buildConfigurationName = assemblyConfigurationAttribute?.Configuration;
- IsDebug = buildConfigurationName == "Debug";
-
GameFrame.Initialize(this);
Log(LogLevel.Info, $"Plugin is loaded [version: {MyPluginInfo.PLUGIN_VERSION}]");
diff --git a/XPShared/Services/ChatService.cs b/XPShared/Services/ChatService.cs
index 4cee188..f2ab7ee 100644
--- a/XPShared/Services/ChatService.cs
+++ b/XPShared/Services/ChatService.cs
@@ -168,5 +168,5 @@ internal class ChatEventHandler
{
#nullable disable
internal Action OnReceiveMessage { get; init; }
-#nullable enable
+#nullable restore
}
\ No newline at end of file
diff --git a/XPShared/Transport/Messages/DisplayTextMessage.cs b/XPShared/Transport/Messages/DisplayTextMessage.cs
new file mode 100644
index 0000000..9d52c87
--- /dev/null
+++ b/XPShared/Transport/Messages/DisplayTextMessage.cs
@@ -0,0 +1,28 @@
+namespace XPShared.Transport.Messages;
+
+public class DisplayTextMessage : IChatMessage
+{
+ public string Group = "";
+ public string ID = "";
+ public string Title = "";
+ public string Text = "";
+ public bool Reset = true;
+
+ public void Serialize(BinaryWriter writer)
+ {
+ writer.Write(Group);
+ writer.Write(ID);
+ writer.Write(Title);
+ writer.Write(Text);
+ writer.Write(Reset);
+ }
+
+ public void Deserialize(BinaryReader reader)
+ {
+ Group = reader.ReadString();
+ ID = reader.ReadString();
+ Title = reader.ReadString();
+ Text = reader.ReadString();
+ Reset = reader.ReadBoolean();
+ }
+}
\ No newline at end of file
diff --git a/XPShared/Transport/Messages/ProgressSerialisedMessage.cs b/XPShared/Transport/Messages/ProgressSerialisedMessage.cs
index bb3dfb6..eb9935d 100644
--- a/XPShared/Transport/Messages/ProgressSerialisedMessage.cs
+++ b/XPShared/Transport/Messages/ProgressSerialisedMessage.cs
@@ -7,7 +7,8 @@ public enum ActiveState
Unchanged,
NotActive,
Active,
- OnlyActive
+ OnlyActive,
+ Remove
}
public string Group = "";
diff --git a/XPShared/Transport/Utils.cs b/XPShared/Transport/Utils.cs
index 3324b56..14088eb 100644
--- a/XPShared/Transport/Utils.cs
+++ b/XPShared/Transport/Utils.cs
@@ -6,7 +6,7 @@ namespace XPShared.Transport;
public static class Utils
{
- public static void ServerSetBarData(User playerCharacter, string barGroup, string bar, string header, float progressPercentage, string tooltip, ProgressSerialisedMessage.ActiveState activeState, string colour, string change = "")
+ public static void ServerSetBarData(User playerCharacter, string barGroup, string bar, string header, float progressPercentage, string tooltip, ProgressSerialisedMessage.ActiveState activeState, string colour, string change = "", bool flash = false)
{
var msg = new ProgressSerialisedMessage()
{
@@ -18,7 +18,7 @@ public static void ServerSetBarData(User playerCharacter, string barGroup, strin
Active = activeState,
Colour = colour,
Change = change,
- Flash = change != ""
+ Flash = flash
};
MessageHandler.ServerSendToClient(playerCharacter, msg);
}
@@ -46,4 +46,32 @@ public static void ServerSendNotification(User playerCharacter, string id, strin
};
MessageHandler.ServerSendToClient(playerCharacter, msg);
}
+
+ public static void ServerSendText(User playerCharacter, string group, string id, string title, string text)
+ {
+ var msg = new DisplayTextMessage()
+ {
+ Group = group,
+ ID = id,
+ Title = title,
+ Text = text,
+ Reset = true
+ };
+ MessageHandler.ServerSendToClient(playerCharacter, msg);
+ }
+
+ public static void ServerSendText(User playerCharacter, string group, string id, string title, List text)
+ {
+ foreach (var msg in text.Select((message, index) => new DisplayTextMessage()
+ {
+ Group = group,
+ ID = id,
+ Title = title,
+ Text = message,
+ Reset = index == 0
+ }))
+ {
+ MessageHandler.ServerSendToClient(playerCharacter, msg);
+ }
+ }
}
\ No newline at end of file
diff --git a/XPShared/XPShared.csproj b/XPShared/XPShared.csproj
index 40b9a5e..c4b352d 100644
--- a/XPShared/XPShared.csproj
+++ b/XPShared/XPShared.csproj
@@ -16,7 +16,7 @@
-
+