tips / tutorial system (#5199)

* somewhat functional yes

* some cleanup

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* review

* missed one

* why not params…

* review 2

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Milon
2026-01-14 14:23:39 -06:00
committed by GitHub
co-authored by pre-commit-ci[bot]
parent 885d6e8872
commit e0fd0cb2cd
34 changed files with 6064 additions and 11 deletions
@@ -1,21 +1,18 @@
<tabs:DeltaTab xmlns="https://spacestation14.io"
xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
xmlns:tabs="clr-namespace:Content.Client._DV.Options.UI.Tabs"
xmlns:xNamespace="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:optionsUI="clr-namespace:Content.Client.Options.UI"
xmlns:s="clr-namespace:Content.Client.Stylesheets"
xmlns:ui="clr-namespace:Content.Client.Options.UI"
xmlns:dvui="clr-namespace:Content.Client._DV.Options.UI">
xmlns:ui="clr-namespace:Content.Client.Options.UI">
<BoxContainer Orientation="Vertical">
<BoxContainer Orientation="Vertical" Margin="8 8 8 8" VerticalExpand="True">
<Label Text="{Loc 'ui-options-general-forknotice'}"
FontColorOverride="{xNamespace:Static s:StyleNano.ConcerningOrangeFore}"
StyleClasses="LabelKeyText"/>
<Label Text="{Loc 'ui-options-general-accessibility'}"
FontColorOverride="{xNamespace:Static s:StyleNano.NanoGold}"
StyleClasses="LabelKeyText"/>
<CheckBox Name="DisableFiltersCheckBox" Text="{Loc 'ui-options-no-filters'}" />
<CheckBox Name="DisableGlimmerEffectCheckBox" Text="{Loc 'ui-options-disable-glimmer-effect'}" />
<Label Text="{Loc 'ui-options-delta-tips'}"
StyleClasses="LabelKeyText"/>
<CheckBox Name="DisableTipsCheckBox" Text="{Loc 'ui-options-delta-disable-tips'}" />
<Button Name="ResetTipsButton" Text="{Loc 'ui-options-delta-reset-tips'}" MaxWidth="200" Margin="0 4 0 0" />
</BoxContainer>
<ui:OptionsTabControlRow Name="Control" Access="Public" />
</BoxContainer>
@@ -1,5 +1,5 @@
using Content.Shared._DV.CCVars;
using Content.Client.Options.UI;
using Content.Shared._DV.Tips;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML;
@@ -9,6 +9,8 @@ namespace Content.Client._DV.Options.UI.Tabs;
[GenerateTypedNameReferences]
public sealed partial class DeltaTab : Control
{
[Dependency] private readonly IEntityNetworkManager _entNet = default!;
public DeltaTab()
{
RobustXamlLoader.Load(this);
@@ -16,9 +18,15 @@ public sealed partial class DeltaTab : Control
Control.AddOptionCheckBox(DCCVars.NoVisionFilters, DisableFiltersCheckBox);
Control.AddOptionCheckBox(DCCVars.DisableGlimmerShader, DisableGlimmerEffectCheckBox);
//Control.AddOptionCheckBox(DCCVars.ChatAutoFillHighlights, AutoFillHighlightsCheckBox); // Migrating to the new system
//Control.AddOptionColorSlider(DCCVars.ChatHighlightsColor, HighlightsColorSlider); // Migrating to the new system
Control.AddOptionCheckBox(DCCVars.DisableTips, DisableTipsCheckBox);
ResetTipsButton.OnPressed += _ => OnResetTipsPressed();
Control.Initialize();
}
private void OnResetTipsPressed()
{
_entNet.SendSystemNetworkMessage(new ResetAllSeenTipsRequest());
}
}
+126
View File
@@ -0,0 +1,126 @@
using Content.Client._DV.Tips.UI;
using Content.Shared._DV.CCVars;
using Content.Shared._DV.Tips;
using JetBrains.Annotations;
using Robust.Client.UserInterface;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Client._DV.Tips;
/// <summary>
/// Client-side system that receives tip events from the server and displays the tip popup UI.
/// </summary>
public sealed class TipSystem : SharedTipSystem
{
[Dependency] private readonly IUserInterfaceManager _ui = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
/// <summary>
/// Queue of tips waiting to be shown. Only one tip is displayed at a time.
/// </summary>
private readonly Queue<ShowTipEvent> _tipQueue = new();
/// <summary>
/// Currently displayed tip popup, if any.
/// </summary>
private TipPopup? _currentPopup;
/// <summary>
/// The tip ID of the currently displayed popup, used for dismiss events.
/// </summary>
private ProtoId<TipPrototype>? _currentTipId;
public override void Initialize()
{
base.Initialize();
SubscribeNetworkEvent<ShowTipEvent>(OnShowTip);
}
private void OnShowTip(ShowTipEvent ev)
{
// Return if tips are disabled globally
if (_cfg.GetCVar(DCCVars.DisableTipsGlobal))
return;
// Return if tips are disabled locally and IgnoreCvar is the default value
if (_cfg.GetCVar(DCCVars.DisableTips) && !ev.IgnoreCvar)
return;
_tipQueue.Enqueue(ev);
TryShowNextTip();
}
private void TryShowNextTip()
{
while (true)
{
if (_currentPopup != null)
return;
if (_tipQueue.Count == 0)
return;
var tipEvent = _tipQueue.Dequeue();
if (!Prototype.TryIndex(tipEvent.TipId, out var tipProto))
{
Log.Warning($"Received tip event for unknown prototype: {tipEvent.TipId}");
continue;
}
ShowTipPopup(tipProto, tipEvent);
break;
}
}
private void ShowTipPopup(TipPrototype tip, ShowTipEvent ev)
{
_currentPopup = new TipPopup(tip);
_currentPopup.OnClose += OnPopupClosed;
_currentTipId = tip.ID;
_ui.RootControl.AddChild(_currentPopup);
// Play the sound
_audio.PlayGlobal(ev.Sound, Filter.Local(), false);
}
private void OnPopupClosed(bool dontShowAgain)
{
if (_currentPopup != null && _currentTipId != null)
{
// Send dismiss event to server
RaiseNetworkEvent(new TipDismissedEvent(_currentTipId.Value, dontShowAgain));
_currentPopup.OnClose -= OnPopupClosed;
_currentPopup?.Close();
_currentPopup = null;
_currentTipId = null;
}
TryShowNextTip();
}
/// <summary>
/// Closes the current tip popup if one is open.
/// </summary>
[PublicAPI]
public void CloseCurrentTip()
{
_currentPopup?.Close();
}
/// <summary>
/// Clears all queued tips without showing them.
/// </summary>
[PublicAPI]
public void ClearTipQueue()
{
_tipQueue.Clear();
}
}
+41
View File
@@ -0,0 +1,41 @@
<Control xmlns="https://spacestation14.io"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="80 100 0 0">
<PanelContainer Name="BackgroundPanel"
MinWidth="320"
MaxWidth="420">
<BoxContainer Orientation="Vertical"
Margin="15">
<!-- Title at the top, centered -->
<Label Name="TitleLabel"
StyleClasses="LabelBig"
HorizontalAlignment="Center"
Margin="0 0 0 10" />
<!-- Description in a flexible vertical container -->
<PanelContainer StyleClasses="BackgroundDark"
Margin="0 0 0 12">
<BoxContainer Orientation="Vertical"
Margin="10">
<RichTextLabel Name="DescriptionLabel"
HorizontalAlignment="Left"
VerticalExpand="True" />
</BoxContainer>
</PanelContainer>
<!-- Bottom controls -->
<BoxContainer Orientation="Vertical"
HorizontalAlignment="Center">
<CheckBox Name="DontShowAgainCheckBox"
Text="{Loc 'tip-popup-dont-show-again'}"
HorizontalAlignment="Center"
Margin="0 0 0 8" />
<Button Name="OkayButton"
Text="{Loc 'tip-popup-okay-button'}"
HorizontalAlignment="Center"
MinWidth="100" />
</BoxContainer>
</BoxContainer>
</PanelContainer>
</Control>
@@ -0,0 +1,98 @@
using Content.Client.Guidebook.Richtext;
using Content.Shared._DV.Tips;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.RichText;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Utility;
namespace Content.Client._DV.Tips.UI;
/// <summary>
/// Popup control that displays a tip to the player.
/// Appears in the top-left corner with a semi-transparent background.
/// </summary>
[GenerateTypedNameReferences]
public sealed partial class TipPopup : Control
{
/// <summary>
/// Event raised when the popup is closed. The bool parameter indicates
/// whether the "Don't show again" checkbox was checked.
/// </summary>
public event Action<bool>? OnClose;
private readonly TipPrototype _tip;
/// <summary>
/// Whether the "Don't show again" checkbox is checked.
/// </summary>
public bool DontShowAgain => DontShowAgainCheckBox.Pressed;
public TipPopup(TipPrototype tip)
{
RobustXamlLoader.Load(this);
_tip = tip;
InitializeUI();
InitializeEvents();
}
private void InitializeUI()
{
TitleLabel.Text = Loc.GetString(_tip.Title);
var description = Loc.GetString(_tip.Description);
DescriptionLabel.SetMessage(FormattedMessage.FromMarkupPermissive(description),
tagsAllowed:
[
typeof(ItalicTag),
typeof(ColorTag),
typeof(BoldTag),
typeof(BoldItalicTag),
typeof(KeyBindTag),
]);
ApplyTransparentStyle();
}
private void ApplyTransparentStyle()
{
var styleBox = new StyleBoxFlat
{
BackgroundColor = Color.FromHex("#1a1a1a").WithAlpha(0.85f),
BorderColor = Color.FromHex("#3a3a3a"),
BorderThickness = new Thickness(1),
ContentMarginLeftOverride = 0,
ContentMarginRightOverride = 0,
ContentMarginTopOverride = 0,
ContentMarginBottomOverride = 0,
};
BackgroundPanel.PanelOverride = styleBox;
}
private void InitializeEvents()
{
OkayButton.OnPressed += OnOkayPressed;
}
private void OnOkayPressed(BaseButton.ButtonEventArgs args)
{
Close();
}
public void Close()
{
if (Parent == null)
{
return;
}
var dontShowAgain = DontShowAgain;
Parent.RemoveChild(this);
OnClose?.Invoke(dontShowAgain);
}
}
+39
View File
@@ -0,0 +1,39 @@
// File to store DeltaV-specific database models
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Content.Server.Database;
// ReSharper disable once InconsistentNaming
public static class DVModel
{
/// <summary>
/// Stores tip dismissal preferences for a player, tracking which tips they've marked
/// as "Don't show again".
/// </summary>
[Table("dv_seen_tips")]
[Index(nameof(PlayerUserId), nameof(TipProtoId), IsUnique = true)]
public class SeenTip
{
public int Id { get; set; }
/// <summary>
/// The player's user ID (GUID). References the Player table.
/// </summary>
public Guid PlayerUserId { get; set; }
/// <summary>
/// The prototype ID of the tip that was dismissed.
/// </summary>
[MaxLength(64)]
public string TipProtoId { get; set; } = string.Empty;
/// <summary>
/// When the tip was dismissed.
/// </summary>
public DateTime DismissedAt { get; set; }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class DVSeenTips : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "dv_seen_tips",
columns: table => new
{
dv_seen_tips_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
player_user_id = table.Column<Guid>(type: "uuid", nullable: false),
tip_proto_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
dismissed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_dv_seen_tips", x => x.dv_seen_tips_id);
table.ForeignKey(
name: "FK_dv_seen_tips_player_player_id",
column: x => x.player_user_id,
principalTable: "player",
principalColumn: "user_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_dv_seen_tips_player_user_id_tip_proto_id",
table: "dv_seen_tips",
columns: new[] { "player_user_id", "tip_proto_id" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "dv_seen_tips");
}
}
}
@@ -711,6 +711,39 @@ namespace Content.Server.Database.Migrations.Postgres
});
});
modelBuilder.Entity("Content.Server.Database.DVModel+SeenTip", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("dv_seen_tips_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("DismissedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("dismissed_at");
b.Property<Guid>("PlayerUserId")
.HasColumnType("uuid")
.HasColumnName("player_user_id");
b.Property<string>("TipProtoId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("tip_proto_id");
b.HasKey("Id")
.HasName("PK_dv_seen_tips");
b.HasIndex("PlayerUserId", "TipProtoId")
.IsUnique()
.HasDatabaseName("IX_dv_seen_tips_player_user_id_tip_proto_id");
b.ToTable("dv_seen_tips", (string)null);
});
modelBuilder.Entity("Content.Server.Database.IPIntelCache", b =>
{
b.Property<int>("Id")
@@ -1801,6 +1834,17 @@ namespace Content.Server.Database.Migrations.Postgres
b.Navigation("Server");
});
modelBuilder.Entity("Content.Server.Database.DVModel+SeenTip", b =>
{
b.HasOne("Content.Server.Database.Player", null)
.WithMany()
.HasForeignKey("PlayerUserId")
.HasPrincipalKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_dv_seen_tips_player_player_id");
});
modelBuilder.Entity("Content.Server.Database.Job", b =>
{
b.HasOne("Content.Server.Database.Profile", "Profile")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
/// <inheritdoc />
public partial class DVSeenTips : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "dv_seen_tips",
columns: table => new
{
dv_seen_tips_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
player_user_id = table.Column<Guid>(type: "TEXT", nullable: false),
tip_proto_id = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
dismissed_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_dv_seen_tips", x => x.dv_seen_tips_id);
table.ForeignKey(
name: "FK_dv_seen_tips_player_player_id",
column: x => x.player_user_id,
principalTable: "player",
principalColumn: "user_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_dv_seen_tips_player_user_id_tip_proto_id",
table: "dv_seen_tips",
columns: new[] { "player_user_id", "tip_proto_id" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "dv_seen_tips");
}
}
}
@@ -671,6 +671,37 @@ namespace Content.Server.Database.Migrations.Sqlite
b.ToTable("connection_log", (string)null);
});
modelBuilder.Entity("Content.Server.Database.DVModel+SeenTip", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("dv_seen_tips_id");
b.Property<DateTime>("DismissedAt")
.HasColumnType("TEXT")
.HasColumnName("dismissed_at");
b.Property<Guid>("PlayerUserId")
.HasColumnType("TEXT")
.HasColumnName("player_user_id");
b.Property<string>("TipProtoId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("tip_proto_id");
b.HasKey("Id")
.HasName("PK_dv_seen_tips");
b.HasIndex("PlayerUserId", "TipProtoId")
.IsUnique()
.HasDatabaseName("IX_dv_seen_tips_player_user_id_tip_proto_id");
b.ToTable("dv_seen_tips", (string)null);
});
modelBuilder.Entity("Content.Server.Database.IPIntelCache", b =>
{
b.Property<int>("Id")
@@ -1721,6 +1752,17 @@ namespace Content.Server.Database.Migrations.Sqlite
b.Navigation("Server");
});
modelBuilder.Entity("Content.Server.Database.DVModel+SeenTip", b =>
{
b.HasOne("Content.Server.Database.Player", null)
.WithMany()
.HasForeignKey("PlayerUserId")
.HasPrincipalKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_dv_seen_tips_player_player_id");
});
modelBuilder.Entity("Content.Server.Database.Job", b =>
{
b.HasOne("Content.Server.Database.Profile", "Profile")
+10
View File
@@ -46,6 +46,7 @@ namespace Content.Server.Database
public DbSet<RoleWhitelist> RoleWhitelists { get; set; } = null!;
public DbSet<BanTemplate> BanTemplate { get; set; } = null!;
public DbSet<IPIntelCache> IPIntelCache { get; set; } = null!;
public DbSet<DVModel.SeenTip> DVSeenTips { get; set; } = null!; // DeltaV - Tips
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -71,6 +72,15 @@ namespace Content.Server.Database
.IsRequired();
// End CD - CD Character Data
// DeltaV - Tips
modelBuilder.Entity<DVModel.SeenTip>()
.HasOne<Player>()
.WithMany()
.HasForeignKey(s => s.PlayerUserId)
.HasPrincipalKey(p => p.UserId)
.IsRequired();
// End DeltaV
modelBuilder.Entity<Antag>()
.HasIndex(p => new {HumanoidProfileId = p.ProfileId, p.AntagName})
.IsUnique();
+77
View File
@@ -24,6 +24,7 @@ using Robust.Shared.Prototypes;
using Robust.Shared.Utility;
using Content.Server._CD.Records; // CD - Character Records
using Content.Shared._CD.Records; // CD - Character Records
using Content.Shared._DV.Tips; // DV - Tips
namespace Content.Server.Database
{
@@ -1849,6 +1850,82 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
#endregion
#region DV - Seen Tips
public async Task<HashSet<string>> GetSeenTips(Guid player, CancellationToken cancel = default)
{
await using var db = await GetDb(cancel);
var seenTips = await db.DbContext.DVSeenTips
.Where(s => s.PlayerUserId == player)
.Select(s => s.TipProtoId)
.ToListAsync(cancel);
return seenTips.ToHashSet();
}
public async Task<bool> HasSeenTip(Guid player, ProtoId<TipPrototype> tip)
{
await using var db = await GetDb();
return await db.DbContext.DVSeenTips
.Where(s => s.PlayerUserId == player)
.Where(s => s.TipProtoId == tip.Id)
.AnyAsync();
}
public async Task<bool> MarkTipSeen(Guid player, ProtoId<TipPrototype> tip)
{
await using var db = await GetDb();
var exists = await db.DbContext.DVSeenTips
.Where(s => s.PlayerUserId == player)
.Where(s => s.TipProtoId == tip.Id)
.AnyAsync();
if (exists)
return false;
var seenTip = new DVModel.SeenTip
{
PlayerUserId = player,
TipProtoId = tip.Id,
DismissedAt = DateTime.UtcNow
};
db.DbContext.DVSeenTips.Add(seenTip);
await db.DbContext.SaveChangesAsync();
return true;
}
public async Task<bool> ResetSeenTip(Guid player, ProtoId<TipPrototype> tip)
{
await using var db = await GetDb();
var entry = await db.DbContext.DVSeenTips
.Where(s => s.PlayerUserId == player)
.Where(s => s.TipProtoId == tip.Id)
.SingleOrDefaultAsync();
if (entry == null)
return false;
db.DbContext.DVSeenTips.Remove(entry);
await db.DbContext.SaveChangesAsync();
return true;
}
public async Task<int> ResetAllSeenTips(Guid player)
{
await using var db = await GetDb();
return await db.DbContext.DVSeenTips
.Where(s => s.PlayerUserId == player)
.ExecuteDeleteAsync();
}
#endregion
public abstract Task SendNotification(DatabaseNotification notification);
// SQLite returns DateTime as Kind=Unspecified, Npgsql actually knows for sure it's Kind=Utc.
@@ -5,6 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Administration.Logs;
using Content.Shared._DV.Tips; // DeltaV - Tips
using Content.Shared.Administration.Logs;
using Content.Shared.CCVar;
using Content.Shared.Construction.Prototypes;
@@ -343,6 +344,16 @@ namespace Content.Server.Database
#endregion
#region DeltaV - Seen Tips
Task<HashSet<string>> GetSeenTips(Guid player, CancellationToken cancel = default);
Task<bool> HasSeenTip(Guid player, ProtoId<TipPrototype> tip);
Task<bool> MarkTipSeen(Guid player, ProtoId<TipPrototype> tip);
Task<bool> ResetSeenTip(Guid player, ProtoId<TipPrototype> tip);
Task<int> ResetAllSeenTips(Guid player);
#endregion
#region DB Notifications
void SubscribeToNotifications(Action<DatabaseNotification> handler);
@@ -1052,6 +1063,40 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.CleanIPIntelCache(range));
}
#region DeltaV - Seen Tips
public Task<HashSet<string>> GetSeenTips(Guid player, CancellationToken cancel = default)
{
DbReadOpsMetric.Inc();
return RunDbCommand(() => _db.GetSeenTips(player, cancel));
}
public Task<bool> HasSeenTip(Guid player, ProtoId<TipPrototype> tip)
{
DbReadOpsMetric.Inc();
return RunDbCommand(() => _db.HasSeenTip(player, tip));
}
public Task<bool> MarkTipSeen(Guid player, ProtoId<TipPrototype> tip)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.MarkTipSeen(player, tip));
}
public Task<bool> ResetSeenTip(Guid player, ProtoId<TipPrototype> tip)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.ResetSeenTip(player, tip));
}
public Task<int> ResetAllSeenTips(Guid player)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.ResetAllSeenTips(player));
}
#endregion
public void SubscribeToNotifications(Action<DatabaseNotification> handler)
{
lock (_notificationHandlers)
@@ -18,6 +18,7 @@ using Robust.Shared.Random;
using System.Linq;
using System.Text;
using Content.Server.Codewords;
using Content.Shared._DV.Tips;
namespace Content.Server.GameTicking.Rules;
@@ -35,6 +36,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
[Dependency] private readonly SharedRoleSystem _roleSystem = default!;
[Dependency] private readonly UplinkSystem _uplink = default!;
[Dependency] private readonly CodewordSystem _codewordSystem = default!;
[Dependency] private readonly SharedTipSystem _tips = default!; // DeltaV - Tips
public override void Initialize()
{
@@ -143,6 +145,7 @@ public sealed class TraitorRuleSystem : GameRuleSystem<TraitorRuleComponent>
_npcFaction.AddFaction(traitor, component.SyndicateFaction);
Log.Debug($"MakeTraitor {ToPrettyString(traitor)} - Finished");
_tips.ShowTip(traitor, new ProtoId<TipPrototype>("TraitorTip")); // DeltaV - Tips
return true;
}
+61
View File
@@ -0,0 +1,61 @@
using Robust.Shared.Console;
using Robust.Shared.Prototypes;
using Content.Shared.Administration;
using Content.Server.Administration;
using Content.Shared._DV.Tips;
using Robust.Shared.Player;
namespace Content.Server._DV.Tips;
[AdminCommand(AdminFlags.Fun)]
public sealed class ShowTipCommand : LocalizedEntityCommands
{
public override string Command => "showtip";
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly ISharedPlayerManager _player = default!;
[Dependency] private readonly SharedTipSystem _tip = default!;
public override CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
return args.Length switch
{
1 =>
CompletionResult.FromHintOptions(
CompletionHelper.SessionNames(players: _player),
Loc.GetString("shell-argument-username-hint")),
2 =>
CompletionResult.FromHintOptions(
CompletionHelper.PrototypeIDs<TipPrototype>(proto: _prototype),
Loc.GetString("cmd-showtip-hint-tip")),
_ => CompletionResult.Empty
};
}
public override async void Execute(IConsoleShell shell, string argStr, string[] args)
{
if (args.Length is 0 or > 2)
{
shell.WriteError(LocalizationManager.GetString("shell-wrong-arguments-number"));
return;
}
if (!_player.TryGetSessionByUsername(args[0], out var player))
{
shell.WriteError(LocalizationManager.GetString("shell-target-player-does-not-exist"));
return;
}
if (!_prototype.TryIndex<TipPrototype>(args[1], out var proto))
{
shell.WriteError(Loc.GetString($"shell-argument-must-be-prototype",
("index", args[1]),
("prototype", nameof(TipPrototype))));
return;
}
_tip.ShowTip(player, proto, true);
shell.WriteLine(Loc.GetString("shell-command-success"));
}
}
+331
View File
@@ -0,0 +1,331 @@
using Content.Server.Database;
using Content.Server.Mind;
using Content.Server.Players.PlayTimeTracking;
using Content.Server.Roles.Jobs;
using Content.Server.Tips;
using Content.Shared._DV.Tips;
using Content.Shared._DV.Tips.Conditions;
using Content.Shared.GameTicking;
using Content.Shared.Roles.Components;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
namespace Content.Server._DV.Tips;
/// <summary>
/// Server-side system that handles showing tips to players after they spawn alongside validation.
/// Not to be confused with <see cref="TipsSystem"/>.
/// </summary>
public sealed class TipSystem : SharedTipSystem
{
[Dependency] private readonly IComponentFactory _component = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IServerDbManager _db = default!;
[Dependency] private readonly JobSystem _job = default!;
[Dependency] private readonly PlayTimeTrackingManager _playtime = default!;
/// <summary>
/// Tracks scheduled tips for each player session.
/// </summary>
private readonly Dictionary<ICommonSession, List<ScheduledTip>> _scheduledTips = new();
/// <summary>
/// Cache of seen tips per player to avoid repeated DB queries.
/// </summary>
private readonly Dictionary<NetUserId, HashSet<string>> _seenTipsCache = new();
private readonly List<ICommonSession> _toRemove = new();
private sealed class ScheduledTip
{
public ProtoId<TipPrototype> TipId;
public TimeSpan ShowTime;
public EntityUid PlayerEntity;
}
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PlayerSpawnCompleteEvent>(OnPlayerSpawnComplete);
SubscribeLocalEvent<RoundRestartCleanupEvent>(OnRoundRestart);
SubscribeNetworkEvent<TipDismissedEvent>(OnTipDismissed);
SubscribeNetworkEvent<ResetAllSeenTipsRequest>(OnResetAllSeenTipsRequest);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var currentTime = _timing.CurTime;
_toRemove.Clear();
foreach (var (session, tips) in _scheduledTips)
{
if (session.Status != SessionStatus.InGame)
{
_toRemove.Add(session);
continue;
}
for (var i = tips.Count - 1; i >= 0; i--)
{
var scheduled = tips[i];
if (currentTime < scheduled.ShowTime)
continue;
// Re-check conditions at show time in case state changed
if (Prototype.TryIndex(scheduled.TipId, out var tipProto) &&
CheckConditions(scheduled.PlayerEntity, session, tipProto))
{
ShowTip(session, tipProto);
}
tips.RemoveAt(i);
}
if (tips.Count == 0)
_toRemove.Add(session);
}
foreach (var session in _toRemove)
{
_scheduledTips.Remove(session);
}
}
private void OnPlayerSpawnComplete(PlayerSpawnCompleteEvent ev)
{
if (ev.Player is not { } session)
return;
ScheduleTipsForPlayer(session, ev.Mob);
}
private void OnRoundRestart(RoundRestartCleanupEvent ev)
{
_scheduledTips.Clear();
_seenTipsCache.Clear();
}
private void OnTipDismissed(TipDismissedEvent ev, EntitySessionEventArgs args)
{
if (args.SenderSession is not { } session)
return;
// Only persist if the player checked "Don't show again"
if (!ev.DontShowAgain)
return;
MarkTipSeen(session.UserId, ev.TipId);
}
private void OnResetAllSeenTipsRequest(ResetAllSeenTipsRequest ev, EntitySessionEventArgs args)
{
if (args.SenderSession is not { } session)
return;
ResetAllSeenTips(session.UserId);
Log.Info($"Player {session.Name} reset all seen tips.");
}
/// <summary>
/// Marks a tip as seen for a player, updating both cache and database.
/// </summary>
public async void MarkTipSeen(NetUserId player, ProtoId<TipPrototype> tipId)
{
// Update cache immediately
if (!_seenTipsCache.TryGetValue(player, out var seenTips))
{
seenTips = new HashSet<string>();
_seenTipsCache[player] = seenTips;
}
seenTips.Add(tipId.Id);
// Persist to database
await _db.MarkTipSeen(player, tipId);
}
/// <summary>
/// Resets a seen tip for a player, allowing it to be shown again.
/// </summary>
public async void ResetSeenTip(NetUserId player, ProtoId<TipPrototype> tipId)
{
// Update cache
if (_seenTipsCache.TryGetValue(player, out var seenTips))
{
seenTips.Remove(tipId.Id);
}
// Update database
await _db.ResetSeenTip(player, tipId);
}
/// <summary>
/// Resets all seen tips for a player.
/// </summary>
public async void ResetAllSeenTips(NetUserId player)
{
// Clear cache
_seenTipsCache.Remove(player);
// Clear database
await _db.ResetAllSeenTips(player);
}
/// <summary>
/// Checks if a tip has been seen by a player.
/// </summary>
public bool HasSeenTip(NetUserId player, ProtoId<TipPrototype> tipId)
{
if (!_seenTipsCache.TryGetValue(player, out var seenTips))
return false;
return seenTips.Contains(tipId.Id);
}
private async void ScheduleTipsForPlayer(ICommonSession session, EntityUid playerEntity)
{
// Load seen tips from database if not cached
if (!_seenTipsCache.ContainsKey(session.UserId))
{
var seenTips = await _db.GetSeenTips(session.UserId);
_seenTipsCache[session.UserId] = seenTips;
}
var currentTime = _timing.CurTime;
var tips = new List<ScheduledTip>();
foreach (var tipProto in Prototype.EnumeratePrototypes<TipPrototype>())
{
// Skip tips the player has already dismissed with "Don't show again"
if (HasSeenTip(session.UserId, tipProto.ID))
continue;
// Check conditions at schedule time
if (!CheckConditions(playerEntity, session, tipProto))
continue;
tips.Add(new ScheduledTip
{
TipId = tipProto.ID,
ShowTime = currentTime + tipProto.Delay,
PlayerEntity = playerEntity
});
}
if (tips.Count == 0)
return;
// Sort by priority then show time
tips.Sort((a, b) =>
{
var protoA = Prototype.Index(a.TipId);
var protoB = Prototype.Index(b.TipId);
var priorityCompare = protoA.Priority.CompareTo(protoB.Priority);
return priorityCompare != 0 ? priorityCompare : a.ShowTime.CompareTo(b.ShowTime);
});
_scheduledTips.Remove(session);
_scheduledTips[session] = tips;
}
/// <summary>
/// Checks if all conditions for a tip are met.
/// </summary>
private bool CheckConditions(EntityUid player, ICommonSession session, TipPrototype tip)
{
foreach (var condition in tip.Conditions)
{
var result = EvaluateCondition(player, session, condition);
// Apply inversion
result ^= condition.Invert;
if (!result)
return false;
}
return true;
}
/// <summary>
/// Evaluates a single condition based on its type.
/// </summary>
private bool EvaluateCondition(EntityUid player, ICommonSession session, TipCondition condition)
{
return condition switch
{
HasCompCondition hasComp => EvaluateHasComp(player, hasComp),
HasJobCondition hasJob => EvaluateHasJob(player, hasJob),
HasRoleTypeCondition hasRoleType => EvaluateHasRoleType(player, hasRoleType),
MinPlaytimeCondition minTracker => EvaluateMinPlaytime(session, minTracker),
MaxPlaytimeCondition maxTracker => EvaluateMaxPlaytime(session, maxTracker),
_ => true // Unknown condition types pass by default
};
}
private bool EvaluateHasComp(EntityUid player, HasCompCondition condition)
{
if (!_component.TryGetRegistration(condition.Comp, out var registration))
{
Log.Warning($"Tip condition references unknown component: {condition.Comp}");
return false;
}
return HasComp(player, registration.Type);
}
private bool EvaluateHasJob(EntityUid player, HasJobCondition condition)
{
if (!_job.MindTryGetJobId(Mind.GetMind(player), out var jobId))
return false;
return jobId == condition.Job;
}
private bool EvaluateHasRoleType(EntityUid player, HasRoleTypeCondition condition)
{
if (!Mind.TryGetMind(player, out _, out var mind))
return false;
foreach (var role in mind.MindRoleContainer.ContainedEntities)
{
if (!TryComp<MindRoleComponent>(role, out var mindRole))
continue;
if (mindRole.RoleType == condition.RoleType)
return true;
}
return false;
}
private bool EvaluateMinPlaytime(ICommonSession session, MinPlaytimeCondition condition)
{
if (!_playtime.TryGetTrackerTime(session, condition.Tracker, out var time))
return false;
return time.Value >= condition.Time;
}
private bool EvaluateMaxPlaytime(ICommonSession session, MaxPlaytimeCondition condition)
{
if (!_playtime.TryGetTrackerTime(session, condition.Tracker, out var time))
return true; // No time tracked = 0 minutes, which is less than any positive max
return time.Value < condition.Time;
}
/// <summary>
/// Cancels all scheduled tips for a player.
/// </summary>
public void CancelTipsForPlayer(ICommonSession session)
{
_scheduledTips.Remove(session);
}
}
+12
View File
@@ -100,6 +100,18 @@ public sealed partial class DCCVars
public static readonly CVarDef<bool> DisableGlimmerShader =
CVarDef.Create("accessibility.disable_glimmer_shader", false, CVar.CLIENTONLY | CVar.ARCHIVE);
/// <summary>
/// Disables all tips for a player.
/// </summary>
public static readonly CVarDef<bool> DisableTips =
CVarDef.Create("game.disable_tips", false, CVar.CLIENTONLY | CVar.ARCHIVE);
/// <summary>
/// Disables all tips for ALL PLAYERS.
/// </summary>
public static readonly CVarDef<bool> DisableTipsGlobal =
CVarDef.Create("game.disable_tips_global", false, CVar.ARCHIVE | CVar.SERVER | CVar.REPLICATED);
/// <summary>
/// Whether the Shipyard is enabled.
/// </summary>
@@ -0,0 +1,29 @@
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared._DV.Tips.Conditions;
/// <summary>
/// Condition that checks if the player entity has a specific component.
/// </summary>
public sealed partial class HasCompCondition : TipCondition
{
private ISawmill _sawmill = default!;
/// <summary>
/// The component name to check for.
/// </summary>
[DataField(required: true, customTypeSerializer: typeof(ComponentNameSerializer))]
public string Comp = string.Empty;
protected override bool EvaluateImplementation(TipConditionContext ctx)
{
_sawmill = ctx.LogMan.GetSawmill("HasCompCondition");
if (!ctx.CompFactory.TryGetRegistration(Comp, out var registration))
{
_sawmill.Warning("tip", $"Tip condition references unknown component: {Comp}");
return false;
}
return ctx.EntMan.HasComponent(ctx.Player, registration.Type);
}
}
@@ -0,0 +1,23 @@
using Content.Shared.Roles;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Tips.Conditions;
/// <summary>
/// Condition that checks if the player has a specific job.
/// </summary>
public sealed partial class HasJobCondition : TipCondition
{
/// <summary>
/// The job prototype ID to check for.
/// </summary>
[DataField(required: true)]
public ProtoId<JobPrototype> Job;
protected override bool EvaluateImplementation(TipConditionContext ctx)
{
// This needs to be checked server-side with JobSystem
// Return true here as a placeholder - actual check is in server TipSystem
return true;
}
}
@@ -0,0 +1,24 @@
using Content.Shared.Mind;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Tips.Conditions;
/// <summary>
/// Condition that checks if the player's mind has a specific role type.
/// Used for checking antag status (e.g., "SoloAntagonist", "TeamAntagonist", "FreeAgent").
/// </summary>
public sealed partial class HasRoleTypeCondition : TipCondition
{
/// <summary>
/// The role type prototype to check for.
/// </summary>
[DataField(required: true)]
public ProtoId<RoleTypePrototype> RoleType;
protected override bool EvaluateImplementation(TipConditionContext ctx)
{
// This needs to be checked server-side
// Return true here as a placeholder - actual check is in server TipSystem
return true;
}
}
@@ -0,0 +1,32 @@
using Content.Shared.Players.PlayTimeTracking;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Tips.Conditions;
/// <summary>
/// Condition that checks if the player has LESS than a specified amount of playtime
/// on a specific playtime tracker.
/// Useful for showing tips to players new to a specific role.
/// </summary>
public sealed partial class MaxPlaytimeCondition : TipCondition
{
/// <summary>
/// The playtime tracker prototype ID to check.
/// </summary>
[DataField(required: true)]
public ProtoId<PlayTimeTrackerPrototype> Tracker;
/// <summary>
/// Maximum playtime allowed on this tracker.
/// Player must have less than this to pass.
/// </summary>
[DataField(required: true)]
public TimeSpan Time;
protected override bool EvaluateImplementation(TipConditionContext ctx)
{
// This needs to be checked server-side with PlayTimeTrackingManager
// Return true here as a placeholder - actual check is in server TipSystem
return true;
}
}
@@ -0,0 +1,30 @@
using Content.Shared.Players.PlayTimeTracking;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Tips.Conditions;
/// <summary>
/// Condition that checks if the player has MORE than a specified amount of playtime
/// on a specific playtime tracker.
/// </summary>
public sealed partial class MinPlaytimeCondition : TipCondition
{
/// <summary>
/// The playtime tracker prototype ID to check.
/// </summary>
[DataField(required: true)]
public ProtoId<PlayTimeTrackerPrototype> Tracker;
/// <summary>
/// Minimum playtime required on this tracker.
/// </summary>
[DataField(required: true)]
public TimeSpan Time;
protected override bool EvaluateImplementation(TipConditionContext ctx)
{
// This needs to be checked server-side with PlayTimeTrackingManager
// Return true here as a placeholder - actual check is in server TipSystem
return true;
}
}
@@ -0,0 +1,44 @@
using JetBrains.Annotations;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Tips.Conditions;
/// <summary>
/// Base class for tip conditions. Implementations check if a tip should be shown to a player.
/// </summary>
[ImplicitDataDefinitionForInheritors, UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
public abstract partial class TipCondition
{
/// <summary>
/// If true, inverts the result of the condition.
/// </summary>
[DataField]
public bool Invert;
/// <summary>
/// Evaluates the condition, applying inversion if configured.
/// </summary>
[PublicAPI]
public bool Evaluate(TipConditionContext ctx)
{
var result = EvaluateImplementation(ctx);
return result ^ Invert;
}
protected abstract bool EvaluateImplementation(TipConditionContext ctx);
}
/// <summary>
/// Context passed to tip conditions for evaluation.
/// Contains references to the player and relevant systems.
/// </summary>
public sealed class TipConditionContext
{
public required EntityUid Player { get; init; }
public required ICommonSession Session { get; init; }
public required IEntityManager EntMan { get; init; }
public required IPrototypeManager Proto { get; init; }
public required IComponentFactory CompFactory { get; init; }
public required ILogManager LogMan { get; init; }
}
@@ -0,0 +1,71 @@
using Content.Shared.Mind;
using JetBrains.Annotations;
using Robust.Shared.Audio;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Tips;
/// <summary>
/// Shared tip system that provides the public API for showing tips to players.
/// </summary>
public abstract class SharedTipSystem : EntitySystem
{
[Dependency] protected new readonly IPrototypeManager Prototype = default!;
[Dependency] private readonly ISharedPlayerManager _player = default!;
[Dependency] protected readonly SharedMindSystem Mind = default!;
/// <summary>
/// Shows a tip to a player.
/// </summary>
/// <param name="uid">EntityUid of the player to show the tip to.</param>
/// <param name="tipId"><see cref="TipPrototype"/> to use, wrapped in a <see cref="ProtoId{T}"/></param>
/// <param name="ignoreCvar">Whether to ignore the DisableTips CCvar on the client.</param>
/// <param name="sound">Optional SoundSpecifier, will play the prototype's default sound if null.</param>
[PublicAPI]
public void ShowTip(EntityUid uid, ProtoId<TipPrototype> tipId, bool ignoreCvar = false, SoundSpecifier? sound = null)
{
if (!Prototype.TryIndex(tipId, out var tipProto))
{
Log.Warning($"Attempted to show non-existent tip: {tipId}");
return;
}
if (!Mind.TryGetMind(uid, out _, out var mind) || !_player.TryGetSessionById(mind.UserId, out var session))
return;
ShowTip(session, tipProto, ignoreCvar, sound);
}
/// <summary>
/// Shows a tip to a player.
/// </summary>
/// <param name="session"><see cref="ICommonSession"/> of the player to show the tip to.</param>
/// <param name="tipId"><see cref="TipPrototype"/> to use, wrapped in a <see cref="ProtoId{T}"/></param>
/// <param name="ignoreCvar">Whether to ignore the DisableTips CCvar on the client.</param>
/// <param name="sound">Optional SoundSpecifier, will play the prototype's default sound if null.</param>
[PublicAPI]
public void ShowTip(ICommonSession session, ProtoId<TipPrototype> tipId, bool ignoreCvar = false, SoundSpecifier? sound = null)
{
if (!Prototype.TryIndex(tipId, out var tipProto))
{
Log.Warning($"Attempted to show non-existent tip: {tipId}");
return;
}
ShowTip(session, tipProto, ignoreCvar, sound);
}
/// <summary>
/// Shows a tip to a player.
/// </summary>
/// <param name="session"><see cref="ICommonSession"/> of the player to show the tip to.</param>
/// <param name="tip"><see cref="TipPrototype"/> to use.</param>
/// <param name="ignoreCvar">Whether to ignore the DisableTips CCvar on the client.</param>
/// <param name="sound">Optional SoundSpecifier, will play the prototype's default sound if null.</param>
[PublicAPI]
public void ShowTip(ICommonSession session, TipPrototype tip, bool ignoreCvar = false, SoundSpecifier? sound = null)
{
RaiseNetworkEvent(new ShowTipEvent(tip.ID, sound ?? tip.Sound, ignoreCvar), session);
}
}
+52
View File
@@ -0,0 +1,52 @@
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
namespace Content.Shared._DV.Tips;
/// <summary>
/// Server-to-client message to display a tip popup.
/// </summary>
[Serializable, NetSerializable]
public sealed class ShowTipEvent(ProtoId<TipPrototype> tipId, SoundSpecifier? sound, bool ignoreCvar = false) : EntityEventArgs
{
/// <summary>
/// The prototype ID of the tip to show.
/// </summary>
public ProtoId<TipPrototype> TipId = tipId;
/// <summary>
/// The sound to play when showing the tip.
/// Sent separately since SoundSpecifier may not serialize well across network.
/// </summary>
public SoundSpecifier? Sound = sound;
/// <summary>
/// Whether to ignore the DisableTips CCvar on the client
/// </summary>
public bool IgnoreCvar = ignoreCvar;
}
/// <summary>
/// Event sent from client to server when a tip popup is dismissed.
/// </summary>
[Serializable, NetSerializable]
public sealed class TipDismissedEvent(ProtoId<TipPrototype> tipId, bool dontShowAgain) : EntityEventArgs
{
/// <summary>
/// The prototype ID of the dismissed tip.
/// </summary>
public ProtoId<TipPrototype> TipId = tipId;
/// <summary>
/// Whether the player checked "Don't show again".
/// </summary>
public bool DontShowAgain = dontShowAgain;
}
/// <summary>
/// Event sent from client to server to request resetting all seen tips.
/// </summary>
[Serializable, NetSerializable]
public sealed class ResetAllSeenTipsRequest : EntityEventArgs;
+53
View File
@@ -0,0 +1,53 @@
using Robust.Shared.Audio;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Tips;
/// <summary>
/// Prototype for dynamic tips shown to players after spawning.
/// Tips can have conditions and delays before appearing.
/// </summary>
[Prototype]
public sealed partial class TipPrototype : IPrototype
{
[IdDataField]
public string ID { get; private set; } = default!;
/// <summary>
/// Localization key for the tip title.
/// </summary>
[DataField(required: true)]
public LocId Title;
/// <summary>
/// Localization key for the tip description/body text.
/// Supports rich text formatting.
/// </summary>
[DataField(required: true)]
public LocId Description;
/// <summary>
/// Delay after player spawns before showing this tip.
/// </summary>
[DataField]
public TimeSpan Delay = TimeSpan.FromSeconds(5);
/// <summary>
/// Priority for tip ordering. Lower values show first when multiple tips are queued.
/// </summary>
[DataField]
public int Priority;
/// <summary>
/// Sound to play when the tip is shown.
/// </summary>
[DataField]
public SoundSpecifier? Sound = new SoundPathSpecifier("/Audio/Effects/voteding.ogg");
/// <summary>
/// Conditions that must ALL be met for this tip to be shown.
/// If empty, the tip is always shown.
/// </summary>
[DataField]
public List<Conditions.TipCondition> Conditions = new();
}
@@ -0,0 +1,20 @@
using Content.Shared._DV.Tips;
using Content.Shared.Trigger.Components.Effects;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared._DV.Trigger.Components.Effects;
/// <summary>
/// Shows a tip to the triggering player when triggered.
/// Note: this requires the trigger to be on the user to actually show the tip.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
public sealed partial class ShowTipOnTriggerComponent : BaseXOnTriggerComponent
{
/// <summary>
/// The tip prototype to show when triggered.
/// </summary>
[DataField(required: true), AutoNetworkedField]
public ProtoId<TipPrototype> Tip;
}
@@ -0,0 +1,36 @@
using Content.Shared._DV.Tips;
using Content.Shared._DV.Trigger.Components.Effects;
using Content.Shared.Trigger;
using Robust.Shared.Player;
namespace Content.Shared._DV.Trigger.Systems;
public sealed class ShowTipOnTriggerSystem : EntitySystem
{
[Dependency] private readonly SharedTipSystem _tips = default!;
[Dependency] private readonly ISharedPlayerManager _player = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<ShowTipOnTriggerComponent, TriggerEvent>(OnTrigger);
}
private void OnTrigger(Entity<ShowTipOnTriggerComponent> ent, ref TriggerEvent args)
{
if (args.Key != null && !ent.Comp.KeysIn.Contains(args.Key))
return;
var target = ent.Comp.TargetUser ? args.User : ent.Owner;
if (target == null)
return;
if (!_player.TryGetSessionByEntity(target.Value, out var session))
return;
_tips.ShowTip(session, ent.Comp.Tip);
args.Handled = true;
}
}
@@ -13,3 +13,8 @@ ui-options-function-nano-chat-navigate-down-unread = Navigate down to next unrea
## DeltaV Curator Chat
ui-options-function-open-c-help = Open curator help
## DeltaV Tips
ui-options-delta-tips = Tips
ui-options-delta-disable-tips = Disable tips
ui-options-delta-reset-tips = Reset all seen tips
@@ -0,0 +1,3 @@
cmd-showtip-desc = Shows a tip to a specific player.
cmd-showtip-help = Usage: showhint <username> <prototype>
cmd-showtip-hint-tip = The tip prototype to use.
+35
View File
@@ -0,0 +1,35 @@
# Tip popup UI strings
tip-popup-okay-button = Okay
tip-popup-dont-show-again = Don't show this again
# Example tip content
tip-welcome-title = Welcome to the Station!
tip-welcome-description = Welcome aboard! If you're new, consider asking other crew members for help. Press [color=yellow][keybind="OpenGuidebook"][/color] to open the guidebook at any time.
tip-chat-title = Communication
tip-chat-description = Use [color=yellow][keybind="FocusChatInputWindow"][/color] for local chat, [color=cyan][keybind="FocusRadioWindow"][/color] for radio, and [color=lime]:[/color] prefix for department channels.
tip-newplayer-title = New Player Guide
tip-newplayer-description = It looks like you're new here! Some helpful tips:
• Press [color=yellow][keybind="OpenGuidebook"][/color] to open the guidebook
• Use [color=yellow][keybind="ActivateItemInHand"][/color] to activate items in hand
• Press [color=yellow][keybind="Drop"][/color] to drop items
tip-signlanguage-title = Sign Language
tip-signlanguage-description = You know sign language! Press [color=yellow]J[/color] to open the sign language radial menu and communicate silently with others who understand.
tip-security-title = Security Officer
tip-security-description = As a Security Officer, your job is to [color=yellow]protect and serve[/color] the crew. Remember to always follow Space Law. Your radio prefix is [color=red]:s[/color] for the security channel.
tip-cadet-title = Security Cadet
tip-cadet-description = As a new Security Cadet, stick close to your fellow officers and learn the ropes. Don't be afraid to ask questions! Make sure to read up on the [color=yellow]SOP[/color] and [color=yellow]Space Law[/color].
tip-medical-title = Medical Inter
# TODO: If/when we get ProtoData tag that's generic to all prototypes rather than just EntityPrototype, this should be using that for RadioChannelPrototype
tip-medical-description = As a Medical Doctor, check the [color=cyan]health analyzer[/color] for patient status. Your radio prefix is [color=lightblue]:m[/color] for the medical channel.
tip-traitor-title = Syndicate Agent
tip-traitor-description = You are a Syndicate agent. Check your [color=red]uplink[/color] in your PDA (or wherever you were told) to purchase equipment. Complete your objectives and don't get caught!
tip-veteran-title = Thank You!
tip-veteran-description = Thank you for playing! Your dedication to the station is appreciated. Consider helping new players learn the ropes.
+99
View File
@@ -0,0 +1,99 @@
# Basic tip shown to everyone
- type: tip
id: WelcomeTip
title: tip-welcome-title
description: tip-welcome-description
delay: 5
priority: 0
# Tip with custom sound
- type: tip
id: ChatTip
title: tip-chat-title
description: tip-chat-description
delay: 30
priority: 10
# sound: /Audio/Effects/adminhelp.ogg # not actually bwoinking people because heart attack but you can do that
# Tip for new players (less than 60 minutes total playtime)
- type: tip
id: NewPlayerTip
title: tip-newplayer-title
description: tip-newplayer-description
delay: 10
priority: 1
conditions:
- !type:MaxPlaytimeCondition
tracker: Overall
time: 1h
# Tip for players who understand sign language # TODO placeholder
#- type: tip
# id: SignLanguageTip
# title: tip-signlanguage-title
# description: tip-signlanguage-description
# delay: 15
# priority: 5
# conditions:
# - !type:HasCompCondition
# comp: UnderstandsSignLanguageComponent
# Security job tip
- type: tip
id: SecurityTip
title: tip-security-title
description: tip-security-description
delay: 10
priority: 5
conditions:
- !type:HasJobCondition
job: SecurityOfficer
# Security cadet tip for players new to security (both conditions must pass)
# OR conditions are not supported as of right now
- type: tip
id: SecurityCadetTip
title: tip-cadet-title
description: tip-cadet-description
delay: 8
priority: 3
conditions:
- !type:HasJobCondition
job: SecurityCadet
- !type:MaxPlaytimeCondition
tracker: JobSecurityCadet
time: 2h
# Medical Intern job tip
- type: tip
id: MedicalTip
title: tip-medical-title
description: tip-medical-description
delay: 10
priority: 5
conditions:
- !type:HasJobCondition
job: MedicalIntern
# Tip for traitors, shown when becoming a traitor
- type: tip
id: TraitorTip
title: tip-traitor-title
description: tip-traitor-description
delay: 20
priority: 2
conditions:
- !type:HasRoleTypeCondition
roleType: SoloAntagonist
# Tip for experienced players
#- type: tip
# id: VeteranTip
# title: tip-veteran-title
# description: tip-veteran-description
# delay: 60
# priority: 100
# conditions:
# - !type:MinPlaytimeCondition
# tracker: Overall
# time: 10h