commit 253ec52411e917e179a46cb92622be0013dce44d Author: Axel Meyer Date: Tue Feb 24 17:28:10 2026 +0000 Initial commit: Welcome Screen mod v1.1.0 - Welcome screen GUI dialog on player join - Markdown to VTML converter - Mod checker (recommended/blacklisted mods) - HOME key to reopen - CI/CD pipeline for build + release diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..0221d03 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,77 @@ +name: Build VS Mod + +on: + push: + branches: [master] + tags: ['v*'] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and package mod + run: | + VERSION=$(grep -oPm1 '"version":\s*"\K[^"]+' resources/modinfo.json) + MODID="welcomescreen" + OUTPUT="${MODID}_${VERSION}.zip" + + # Build inside .NET SDK container using docker cp (DinD — bind mounts don't work) + docker volume create mod-build + docker pull alpine:3 >/dev/null 2>&1 + HELPER=$(docker create -v mod-build:/src alpine:3 true) + docker cp . $HELPER:/src/ + docker rm $HELPER >/dev/null + + docker run --rm -v mod-build:/src -w /src mcr.microsoft.com/dotnet/sdk:8.0 \ + dotnet build -c Release + + # Extract artifacts from volume + HELPER=$(docker create -v mod-build:/src alpine:3 true) + docker cp $HELPER:/src/bin/Release/WelcomeScreen.dll ./WelcomeScreen.dll + docker rm $HELPER >/dev/null + docker volume rm mod-build >/dev/null + + # Package ZIP + mkdir -p dist + cp WelcomeScreen.dll dist/ + cp resources/modinfo.json dist/ + cd dist && zip -j "../${OUTPUT}" WelcomeScreen.dll modinfo.json + cd .. + + echo "MOD_ZIP=${OUTPUT}" >> $GITHUB_ENV + echo "MOD_VERSION=${VERSION}" >> $GITHUB_ENV + echo "Built ${OUTPUT}" + + - name: Create release + if: startsWith(github.ref, 'refs/tags/v') + run: | + TAG="${GITHUB_REF#refs/tags/}" + # Generate a temporary token for the API call + TOKEN=$(docker exec gitea gitea admin user generate-access-token \ + --user git --username calic --scopes write 2>&1 | grep -oPm1 '(?<=: ).*') + + # Create release with artifact + RELEASE_ID=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"Welcome Screen ${MOD_VERSION}\",\"body\":\"Release ${MOD_VERSION}\"}" \ + "http://gitea:3000/api/v1/repos/calic/vs-welcome-screen/releases" | grep -oPm1 '"id":\K[0-9]+') + + # Upload artifact + curl -sf \ + -H "Authorization: token ${TOKEN}" \ + -F "attachment=@${MOD_ZIP}" \ + "http://gitea:3000/api/v1/repos/calic/vs-welcome-screen/releases/${RELEASE_ID}/assets" + + # Clean up token + TOKEN_ID=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + "http://gitea:3000/api/v1/users/calic/tokens" | grep -oPm1 '"id":\K[0-9]+') + curl -sf -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "http://gitea:3000/api/v1/users/calic/tokens/${TOKEN_ID}" + + echo "Release ${TAG} created with artifact ${MOD_ZIP}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2789d71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +*.suo +.vs/ diff --git a/WelcomeScreen.csproj b/WelcomeScreen.csproj new file mode 100644 index 0000000..477ee57 --- /dev/null +++ b/WelcomeScreen.csproj @@ -0,0 +1,19 @@ + + + net8.0 + WelcomeScreen + WelcomeScreen + false + false + + + + lib/VintagestoryAPI.dll + false + + + lib/protobuf-net.dll + false + + + diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6e21aec --- /dev/null +++ b/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +# Build and package the Welcome Screen mod for Vintage Story +# Usage: ./build.sh [--install] + +VERSION=$(grep -oP '"version":\s*"\K[^"]+' resources/modinfo.json) +MODID="welcomescreen" +OUTPUT="${MODID}_${VERSION}.zip" +MODS_DIR="/opt/gameservers/vintage-story/server/shared/Mods" + +echo "Building ${MODID} v${VERSION}..." +dotnet build -c Release + +echo "Packaging ${OUTPUT}..." +TMPDIR=$(mktemp -d) +cp bin/Release/WelcomeScreen.dll "$TMPDIR/" +cp resources/modinfo.json "$TMPDIR/" +cd "$TMPDIR" +zip -j "/tmp/${OUTPUT}" WelcomeScreen.dll modinfo.json +rm -rf "$TMPDIR" +cd - > /dev/null + +echo "Built: /tmp/${OUTPUT}" + +if [[ "${1:-}" == "--install" ]]; then + echo "Installing to ${MODS_DIR}..." + rm -f "${MODS_DIR}/${MODID}_"*.zip + cp "/tmp/${OUTPUT}" "${MODS_DIR}/" + chown 1000:1000 "${MODS_DIR}/${OUTPUT}" + echo "Installed. Restart the VS server to apply." +fi diff --git a/lib/VintagestoryAPI.dll b/lib/VintagestoryAPI.dll new file mode 100644 index 0000000..cb834dd Binary files /dev/null and b/lib/VintagestoryAPI.dll differ diff --git a/lib/protobuf-net.dll b/lib/protobuf-net.dll new file mode 100644 index 0000000..c6644d3 Binary files /dev/null and b/lib/protobuf-net.dll differ diff --git a/resources/modinfo.json b/resources/modinfo.json new file mode 100644 index 0000000..f12e75b --- /dev/null +++ b/resources/modinfo.json @@ -0,0 +1,11 @@ +{ + "type": "code", + "modid": "welcomescreen", + "name": "Welcome Screen", + "version": "1.1.0", + "description": "Shows a welcome screen with server rules and info when players join. Includes mod checker that compares client mods against recommended/blacklisted mods. Content loaded from Markdown, rendered as VTML. Press HOME to reopen.", + "authors": ["Calic"], + "side": "universal", + "requiredOnClient": true, + "requiredOnServer": true +} diff --git a/src/MarkdownToVtml.cs b/src/MarkdownToVtml.cs new file mode 100644 index 0000000..666933c --- /dev/null +++ b/src/MarkdownToVtml.cs @@ -0,0 +1,128 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace WelcomeScreen +{ + /// + /// Converts a subset of Markdown to VTML (Vintage Text Markup Language). + /// + /// Supported Markdown syntax: + /// # Heading 1 → large bold gold text + /// ## Heading 2 → medium bold light-green text + /// ### Heading 3 → small bold white text + /// **bold** → bold + /// *italic* → italic + /// [text](url) → text + /// --- → ─────────── (horizontal rule) + /// - list item → • list item + /// 1. numbered item → 1. numbered item (preserved) + /// > blockquote → indented italic text + /// blank line →
+ ///
+ public static class MarkdownToVtml + { + public static string Convert(string markdown) + { + if (string.IsNullOrEmpty(markdown)) + return ""; + + var sb = new StringBuilder(); + string[] lines = markdown.Replace("\r\n", "\n").Split('\n'); + bool lastWasBlank = false; + + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + string trimmed = line.Trim(); + + // Blank line + if (string.IsNullOrWhiteSpace(trimmed)) + { + if (!lastWasBlank) + sb.Append("

"); + lastWasBlank = true; + continue; + } + lastWasBlank = false; + + // Horizontal rule + if (Regex.IsMatch(trimmed, @"^-{3,}$") || Regex.IsMatch(trimmed, @"^\*{3,}$")) + { + sb.Append("
────────────────────────────────
"); + continue; + } + + // Headings + if (trimmed.StartsWith("### ")) + { + string text = ProcessInline(trimmed.Substring(4)); + sb.Append($"{text}
"); + continue; + } + if (trimmed.StartsWith("## ")) + { + string text = ProcessInline(trimmed.Substring(3)); + sb.Append($"{text}
"); + continue; + } + if (trimmed.StartsWith("# ")) + { + string text = ProcessInline(trimmed.Substring(2)); + sb.Append($"{text}
"); + continue; + } + + // Blockquote + if (trimmed.StartsWith("> ")) + { + string text = ProcessInline(trimmed.Substring(2)); + sb.Append($" {text}
"); + continue; + } + + // Unordered list + if (trimmed.StartsWith("- ") || trimmed.StartsWith("* ")) + { + string text = ProcessInline(trimmed.Substring(2)); + sb.Append($" • {text}
"); + continue; + } + + // Ordered list (1. 2. 3. etc.) + var olMatch = Regex.Match(trimmed, @"^(\d+)\.\s+(.+)$"); + if (olMatch.Success) + { + string num = olMatch.Groups[1].Value; + string text = ProcessInline(olMatch.Groups[2].Value); + sb.Append($" {num}. {text}
"); + continue; + } + + // Regular paragraph line + sb.Append(ProcessInline(trimmed)); + sb.Append("
"); + } + + return sb.ToString(); + } + + private static string ProcessInline(string text) + { + // Bold: **text** or __text__ + text = Regex.Replace(text, @"\*\*(.+?)\*\*", "$1"); + text = Regex.Replace(text, @"__(.+?)__", "$1"); + + // Italic: *text* or _text_ (but not inside bold markers) + text = Regex.Replace(text, @"(?$1"); + text = Regex.Replace(text, @"(?$1"); + + // Links: [text](url) + text = Regex.Replace(text, @"\[([^\]]+)\]\(([^)]+)\)", "$1"); + + // Inline code: `code` → just bold it (VTML has no monospace) + text = Regex.Replace(text, @"`([^`]+)`", "$1"); + + return text; + } + } +} diff --git a/src/WelcomeScreenDialog.cs b/src/WelcomeScreenDialog.cs new file mode 100644 index 0000000..49a7ea8 --- /dev/null +++ b/src/WelcomeScreenDialog.cs @@ -0,0 +1,105 @@ +using Vintagestory.API.Client; + +namespace WelcomeScreen +{ + public class WelcomeScreenDialog : GuiDialog + { + private readonly string title; + private readonly string vtmlContent; + + public override string ToggleKeyCombinationCode => "welcomescreen"; + public override bool PrefersUngrabbedMouse => true; + public override bool DisableMouseGrab => true; + + public WelcomeScreenDialog(ICoreClientAPI capi, string title, string vtmlContent) + : base(capi) + { + this.title = title ?? "Welcome"; + this.vtmlContent = vtmlContent ?? ""; + ComposeDialog(); + } + + private void ComposeDialog() + { + int dialogWidth = 550; + int dialogHeight = 450; + int contentHeight = 2000; // Large enough for scrollable content + int buttonHeight = 40; + int padding = 15; + + ElementBounds dialogBounds = ElementStdBounds.AutosizedMainDialog + .WithAlignment(EnumDialogArea.CenterMiddle); + + // Background bounds + ElementBounds bgBounds = ElementBounds.Fixed(0, 0, dialogWidth, dialogHeight + 50) + .WithFixedPadding(GuiStyle.ElementToDialogPadding); + + // Inset area for scrollable content + ElementBounds insetBounds = ElementBounds.Fixed(0, GuiStyle.TitleBarHeight + padding, + dialogWidth, dialogHeight - buttonHeight - padding * 2); + + // Scrollbar + ElementBounds scrollbarBounds = insetBounds.RightCopy().WithFixedWidth(20); + + // Clip area (inside inset) + ElementBounds clipBounds = insetBounds.ForkContainingChild( + GuiStyle.HalfPadding, GuiStyle.HalfPadding, + GuiStyle.HalfPadding, GuiStyle.HalfPadding); + + // Container for richtext content + ElementBounds containerBounds = clipBounds.CopyOffsetedSibling(0, 0); + containerBounds.fixedWidth = clipBounds.fixedWidth - 10; + + // Richtext bounds inside container + ElementBounds richtextBounds = ElementBounds.Fixed(0, 0, + clipBounds.fixedWidth - 20, contentHeight); + + // OK button + ElementBounds buttonBounds = ElementBounds.FixedSize(100, buttonHeight) + .FixedUnder(insetBounds, padding) + .WithAlignment(EnumDialogArea.CenterFixed); + + bgBounds.WithChildren(insetBounds, scrollbarBounds, buttonBounds); + + SingleComposer = capi.Gui.CreateCompo("welcomeScreenDialog", dialogBounds) + .AddShadedDialogBG(bgBounds) + .AddDialogTitleBar(title, OnClose) + .BeginChildElements(bgBounds) + .AddInset(insetBounds, 3) + .BeginClip(clipBounds) + .AddRichtext(vtmlContent, CairoFont.WhiteSmallText(), containerBounds, "richtext") + .EndClip() + .AddVerticalScrollbar(OnScrollbarValue, scrollbarBounds, "scrollbar") + .AddButton("OK", OnOkClicked, buttonBounds) + .EndChildElements() + .Compose(); + + // Set scrollbar height based on actual rendered content height + var richtextElem = SingleComposer.GetRichtext("richtext"); + float totalHeight = richtextElem != null ? (float)richtextElem.Bounds.fixedHeight : contentHeight; + SingleComposer.GetScrollbar("scrollbar") + .SetHeights((float)clipBounds.fixedHeight, totalHeight); + } + + private void OnScrollbarValue(float value) + { + var richtext = SingleComposer.GetRichtext("richtext"); + if (richtext != null) + { + richtext.Bounds.fixedY = 3 - value; + richtext.Bounds.CalcWorldBounds(); + } + } + + private bool OnOkClicked() + { + TryClose(); + return true; + } + + private void OnClose() + { + TryClose(); + } + } +} diff --git a/src/WelcomeScreenMod.cs b/src/WelcomeScreenMod.cs new file mode 100644 index 0000000..31cc0a2 --- /dev/null +++ b/src/WelcomeScreenMod.cs @@ -0,0 +1,377 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using ProtoBuf; +using Vintagestory.API.Client; +using Vintagestory.API.Common; +using Vintagestory.API.Server; + +namespace WelcomeScreen +{ + // --- Network Packets --- + + [ProtoContract] + public class WelcomePacket + { + [ProtoMember(1)] + public string Title; + + [ProtoMember(2)] + public string VtmlContent; + } + + [ProtoContract] + public class ClientModInfo + { + [ProtoMember(1)] + public string ModId; + + [ProtoMember(2)] + public string Version; + } + + [ProtoContract] + public class ClientModListPacket + { + [ProtoMember(1)] + public List Mods; + } + + [ProtoContract] + public class ModCheckResultPacket + { + [ProtoMember(1)] + public string ModStatusVtml; + } + + // --- Config Classes --- + + public class WelcomeConfig + { + public string Title { get; set; } = "Welcome"; + public bool ShowOnEveryJoin { get; set; } = true; + } + + public class RecommendedModEntry + { + public string ModId { get; set; } + public string DisplayName { get; set; } + public string ModDbUrl { get; set; } + public string Reason { get; set; } + } + + public class BlacklistedModEntry + { + public string ModId { get; set; } + public string DisplayName { get; set; } + public string Reason { get; set; } + } + + public class ModCheckConfig + { + public List RecommendedMods { get; set; } = new List(); + public List BlacklistedMods { get; set; } = new List(); + } + + // --- Main Mod --- + + public class WelcomeScreenMod : ModSystem + { + const string ChannelName = "welcomescreen"; + const string ConfigFile = "WelcomeScreenConfig.json"; + const string ModCheckConfigFile = "WelcomeScreenModCheck.json"; + const string ContentFile = "welcome.md"; + + public override void Start(ICoreAPI api) + { + api.Network + .RegisterChannel(ChannelName) + .RegisterMessageType() + .RegisterMessageType() + .RegisterMessageType(); + } + + // ===================== + // SERVER SIDE + // ===================== + + ICoreServerAPI sapi; + IServerNetworkChannel serverChannel; + + public override void StartServerSide(ICoreServerAPI api) + { + sapi = api; + + // Ensure welcome config exists + var config = api.LoadModConfig(ConfigFile); + if (config == null) + { + config = new WelcomeConfig(); + api.StoreModConfig(config, ConfigFile); + } + + // Ensure mod check config exists with defaults + var modCheckConfig = api.LoadModConfig(ModCheckConfigFile); + if (modCheckConfig == null) + { + modCheckConfig = GetDefaultModCheckConfig(); + api.StoreModConfig(modCheckConfig, ModCheckConfigFile); + api.Logger.Notification("[WelcomeScreen] Created default mod check config."); + } + + // Ensure welcome.md exists + string contentPath = GetContentPath(api); + if (!File.Exists(contentPath)) + { + File.WriteAllText(contentPath, GetDefaultMarkdown()); + api.Logger.Notification("[WelcomeScreen] Created default welcome.md at: " + contentPath); + } + + serverChannel = api.Network.GetChannel(ChannelName); + + // Handle client mod list reports + serverChannel.SetMessageHandler(OnClientModListReceived); + + // Send welcome content on join + api.Event.PlayerNowPlaying += OnPlayerNowPlaying; + + api.Logger.Notification("[WelcomeScreen] Server loaded. Content: " + contentPath); + } + + private void OnPlayerNowPlaying(IServerPlayer player) + { + var config = sapi.LoadModConfig(ConfigFile); + string markdown = File.ReadAllText(GetContentPath(sapi)); + string vtml = MarkdownToVtml.Convert(markdown); + + serverChannel.SendPacket(new WelcomePacket + { + Title = config?.Title ?? "Welcome", + VtmlContent = vtml + }, player); + + sapi.Logger.Debug("[WelcomeScreen] Sent welcome screen to " + player.PlayerName); + } + + private void OnClientModListReceived(IServerPlayer player, ClientModListPacket packet) + { + var modCheckConfig = sapi.LoadModConfig(ModCheckConfigFile); + if (modCheckConfig == null) return; + + var clientModIds = new HashSet( + packet.Mods.Select(m => m.ModId.ToLowerInvariant())); + + var clientModVersions = packet.Mods.ToDictionary( + m => m.ModId.ToLowerInvariant(), m => m.Version); + + var sb = new StringBuilder(); + bool hasContent = false; + + // --- Recommended Mods Section --- + if (modCheckConfig.RecommendedMods != null && modCheckConfig.RecommendedMods.Count > 0) + { + hasContent = true; + sb.Append("
────────────────────────────────
"); + sb.Append("Empfohlene Mods

"); + + foreach (var rec in modCheckConfig.RecommendedMods) + { + string id = rec.ModId.ToLowerInvariant(); + bool installed = clientModIds.Contains(id); + + if (installed) + { + string ver = clientModVersions.ContainsKey(id) ? clientModVersions[id] : ""; + sb.Append($""); + sb.Append($"{rec.DisplayName}"); + if (!string.IsNullOrEmpty(ver)) + sb.Append($" v{ver}"); + } + else + { + sb.Append($""); + sb.Append($"{rec.DisplayName}"); + sb.Append($" — nicht installiert"); + } + + if (!string.IsNullOrEmpty(rec.Reason)) + sb.Append($" ({rec.Reason})"); + + if (!installed && !string.IsNullOrEmpty(rec.ModDbUrl)) + sb.Append($" Download"); + + sb.Append("
"); + } + } + + // --- Blacklisted Mods Section --- + if (modCheckConfig.BlacklistedMods != null && modCheckConfig.BlacklistedMods.Count > 0) + { + var violations = modCheckConfig.BlacklistedMods + .Where(b => clientModIds.Contains(b.ModId.ToLowerInvariant())) + .ToList(); + + if (violations.Count > 0) + { + hasContent = true; + sb.Append("
Nicht erlaubte Mods

"); + + foreach (var bl in violations) + { + sb.Append($""); + sb.Append($"{bl.DisplayName ?? bl.ModId}"); + if (!string.IsNullOrEmpty(bl.Reason)) + sb.Append($" — {bl.Reason}"); + sb.Append("
"); + } + + sb.Append("
Bitte entferne diese Mods um auf diesem Server zu spielen.
"); + } + } + + if (hasContent) + { + serverChannel.SendPacket(new ModCheckResultPacket + { + ModStatusVtml = sb.ToString() + }, player); + + sapi.Logger.Debug("[WelcomeScreen] Sent mod check results to " + player.PlayerName + + " (" + packet.Mods.Count + " client mods reported)"); + } + } + + private string GetContentPath(ICoreServerAPI api) + { + string dir = api.GetOrCreateDataPath("ModConfig/WelcomeScreen"); + return Path.Combine(dir, ContentFile); + } + + // ===================== + // CLIENT SIDE + // ===================== + + ICoreClientAPI capi; + WelcomeScreenDialog dialog; + string lastTitle; + string lastWelcomeVtml; + string lastModStatusVtml; + + public override void StartClientSide(ICoreClientAPI api) + { + capi = api; + + var channel = api.Network.GetChannel(ChannelName); + channel.SetMessageHandler(OnWelcomeReceived); + channel.SetMessageHandler(OnModCheckReceived); + + api.Input.RegisterHotKey("welcomescreen", "Open Welcome Screen", GlKeys.Home); + api.Input.SetHotKeyHandler("welcomescreen", OnHotKeyPressed); + } + + private void OnWelcomeReceived(WelcomePacket msg) + { + lastTitle = msg.Title; + lastWelcomeVtml = msg.VtmlContent; + lastModStatusVtml = null; + ShowDialog(); + + // Send our mod list to the server + SendModList(); + } + + private void OnModCheckReceived(ModCheckResultPacket msg) + { + lastModStatusVtml = msg.ModStatusVtml; + // Refresh dialog with mod status appended + ShowDialog(); + } + + private void SendModList() + { + var mods = new List(); + foreach (var mod in capi.ModLoader.Mods) + { + mods.Add(new ClientModInfo + { + ModId = mod.Info.ModID, + Version = mod.Info.Version + }); + } + + capi.Network.GetChannel(ChannelName) + .SendPacket(new ClientModListPacket { Mods = mods }); + } + + private bool OnHotKeyPressed(KeyCombination comb) + { + if (lastWelcomeVtml != null) + ShowDialog(); + return true; + } + + private void ShowDialog() + { + if (dialog != null && dialog.IsOpened()) + dialog.TryClose(); + + string combinedVtml = lastWelcomeVtml ?? ""; + if (!string.IsNullOrEmpty(lastModStatusVtml)) + combinedVtml += lastModStatusVtml; + + dialog = new WelcomeScreenDialog(capi, lastTitle, combinedVtml); + dialog.TryOpen(); + } + + // ===================== + // DEFAULTS + // ===================== + + private ModCheckConfig GetDefaultModCheckConfig() + { + return new ModCheckConfig + { + RecommendedMods = new List + { + new RecommendedModEntry + { + ModId = "rpvoicechat", + DisplayName = "RP Voice Chat", + ModDbUrl = "https://mods.vintagestory.at/rpvoicechat", + Reason = "Proximity Voice Chat" + } + }, + BlacklistedMods = new List() + }; + } + + private string GetDefaultMarkdown() + { + return @"# Willkommen auf unserem Server! + +Schön, dass du da bist. Bitte lies dir die folgenden Regeln durch. + +## Regeln + +1. **Respektvoller Umgang** miteinander +2. **Kein Griefing** — zerstöre nicht die Bauwerke anderer +3. **Kein Stealing** — nimm nichts aus fremden Kisten +4. Halte mindestens **100 Blöcke Abstand** zu anderen Basen +5. **PvP nur mit Einverständnis** beider Spieler + +## Mods + +Dieser Server nutzt zahlreiche Mods. Einige davon müsst ihr client-seitig installieren. +Die empfohlenen Mods werden unten angezeigt. + +## Kontakt + +Bei Fragen wendet euch an den Admin **Calic** im Spiel oder auf Discord. + +--- + +*Drücke HOME um dieses Fenster erneut zu öffnen.*"; + } + } +}