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 @@ - +