Initial commit: Welcome Screen mod v1.1.0
All checks were successful
Build VS Mod / build (push) Successful in 14s
All checks were successful
Build VS Mod / build (push) Successful in 14s
- 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
This commit is contained in:
77
.gitea/workflows/build.yml
Normal file
77
.gitea/workflows/build.yml
Normal file
@@ -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}"
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
.vs/
|
||||||
19
WelcomeScreen.csproj
Normal file
19
WelcomeScreen.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<AssemblyName>WelcomeScreen</AssemblyName>
|
||||||
|
<RootNamespace>WelcomeScreen</RootNamespace>
|
||||||
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="VintagestoryAPI">
|
||||||
|
<HintPath>lib/VintagestoryAPI.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="protobuf-net">
|
||||||
|
<HintPath>lib/protobuf-net.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
32
build.sh
Executable file
32
build.sh
Executable file
@@ -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
|
||||||
BIN
lib/VintagestoryAPI.dll
Normal file
BIN
lib/VintagestoryAPI.dll
Normal file
Binary file not shown.
BIN
lib/protobuf-net.dll
Normal file
BIN
lib/protobuf-net.dll
Normal file
Binary file not shown.
11
resources/modinfo.json
Normal file
11
resources/modinfo.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
128
src/MarkdownToVtml.cs
Normal file
128
src/MarkdownToVtml.cs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WelcomeScreen
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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** → <strong>bold</strong>
|
||||||
|
/// *italic* → <i>italic</i>
|
||||||
|
/// [text](url) → <a href="url">text</a>
|
||||||
|
/// --- → ─────────── (horizontal rule)
|
||||||
|
/// - list item → • list item
|
||||||
|
/// 1. numbered item → 1. numbered item (preserved)
|
||||||
|
/// > blockquote → indented italic text
|
||||||
|
/// blank line → <br>
|
||||||
|
/// </summary>
|
||||||
|
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("<br><br>");
|
||||||
|
lastWasBlank = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastWasBlank = false;
|
||||||
|
|
||||||
|
// Horizontal rule
|
||||||
|
if (Regex.IsMatch(trimmed, @"^-{3,}$") || Regex.IsMatch(trimmed, @"^\*{3,}$"))
|
||||||
|
{
|
||||||
|
sb.Append("<br><font color=\"#666666\">────────────────────────────────</font><br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
if (trimmed.StartsWith("### "))
|
||||||
|
{
|
||||||
|
string text = ProcessInline(trimmed.Substring(4));
|
||||||
|
sb.Append($"<font size=\"18\" weight=\"bold\" color=\"#dddddd\">{text}</font><br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.StartsWith("## "))
|
||||||
|
{
|
||||||
|
string text = ProcessInline(trimmed.Substring(3));
|
||||||
|
sb.Append($"<font size=\"20\" weight=\"bold\" color=\"#88bb88\">{text}</font><br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.StartsWith("# "))
|
||||||
|
{
|
||||||
|
string text = ProcessInline(trimmed.Substring(2));
|
||||||
|
sb.Append($"<font size=\"24\" weight=\"bold\" color=\"#c4a55a\">{text}</font><br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
if (trimmed.StartsWith("> "))
|
||||||
|
{
|
||||||
|
string text = ProcessInline(trimmed.Substring(2));
|
||||||
|
sb.Append($"<font color=\"#aaaaaa\"><i> {text}</i></font><br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list
|
||||||
|
if (trimmed.StartsWith("- ") || trimmed.StartsWith("* "))
|
||||||
|
{
|
||||||
|
string text = ProcessInline(trimmed.Substring(2));
|
||||||
|
sb.Append($" • {text}<br>");
|
||||||
|
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}<br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular paragraph line
|
||||||
|
sb.Append(ProcessInline(trimmed));
|
||||||
|
sb.Append("<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ProcessInline(string text)
|
||||||
|
{
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
|
||||||
|
text = Regex.Replace(text, @"__(.+?)__", "<strong>$1</strong>");
|
||||||
|
|
||||||
|
// Italic: *text* or _text_ (but not inside bold markers)
|
||||||
|
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<i>$1</i>");
|
||||||
|
text = Regex.Replace(text, @"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", "<i>$1</i>");
|
||||||
|
|
||||||
|
// Links: [text](url)
|
||||||
|
text = Regex.Replace(text, @"\[([^\]]+)\]\(([^)]+)\)", "<a href=\"$2\">$1</a>");
|
||||||
|
|
||||||
|
// Inline code: `code` → just bold it (VTML has no monospace)
|
||||||
|
text = Regex.Replace(text, @"`([^`]+)`", "<strong>$1</strong>");
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/WelcomeScreenDialog.cs
Normal file
105
src/WelcomeScreenDialog.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
377
src/WelcomeScreenMod.cs
Normal file
377
src/WelcomeScreenMod.cs
Normal file
@@ -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<ClientModInfo> 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<RecommendedModEntry> RecommendedMods { get; set; } = new List<RecommendedModEntry>();
|
||||||
|
public List<BlacklistedModEntry> BlacklistedMods { get; set; } = new List<BlacklistedModEntry>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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<WelcomePacket>()
|
||||||
|
.RegisterMessageType<ClientModListPacket>()
|
||||||
|
.RegisterMessageType<ModCheckResultPacket>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// SERVER SIDE
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
ICoreServerAPI sapi;
|
||||||
|
IServerNetworkChannel serverChannel;
|
||||||
|
|
||||||
|
public override void StartServerSide(ICoreServerAPI api)
|
||||||
|
{
|
||||||
|
sapi = api;
|
||||||
|
|
||||||
|
// Ensure welcome config exists
|
||||||
|
var config = api.LoadModConfig<WelcomeConfig>(ConfigFile);
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
config = new WelcomeConfig();
|
||||||
|
api.StoreModConfig(config, ConfigFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure mod check config exists with defaults
|
||||||
|
var modCheckConfig = api.LoadModConfig<ModCheckConfig>(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<ClientModListPacket>(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<WelcomeConfig>(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<ModCheckConfig>(ModCheckConfigFile);
|
||||||
|
if (modCheckConfig == null) return;
|
||||||
|
|
||||||
|
var clientModIds = new HashSet<string>(
|
||||||
|
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("<br><font color=\"#666666\">────────────────────────────────</font><br>");
|
||||||
|
sb.Append("<font size=\"20\" weight=\"bold\" color=\"#88bb88\">Empfohlene Mods</font><br><br>");
|
||||||
|
|
||||||
|
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($"<font color=\"#66cc66\"> ✓ </font>");
|
||||||
|
sb.Append($"<strong>{rec.DisplayName}</strong>");
|
||||||
|
if (!string.IsNullOrEmpty(ver))
|
||||||
|
sb.Append($" <font color=\"#888888\">v{ver}</font>");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append($"<font color=\"#cc6666\"> ✗ </font>");
|
||||||
|
sb.Append($"<strong>{rec.DisplayName}</strong>");
|
||||||
|
sb.Append($" <font color=\"#cc6666\">— nicht installiert</font>");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(rec.Reason))
|
||||||
|
sb.Append($" <font color=\"#aaaaaa\">({rec.Reason})</font>");
|
||||||
|
|
||||||
|
if (!installed && !string.IsNullOrEmpty(rec.ModDbUrl))
|
||||||
|
sb.Append($" <a href=\"{rec.ModDbUrl}\">Download</a>");
|
||||||
|
|
||||||
|
sb.Append("<br>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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("<br><font size=\"20\" weight=\"bold\" color=\"#cc6666\">Nicht erlaubte Mods</font><br><br>");
|
||||||
|
|
||||||
|
foreach (var bl in violations)
|
||||||
|
{
|
||||||
|
sb.Append($"<font color=\"#ff4444\"> ⚠ </font>");
|
||||||
|
sb.Append($"<strong>{bl.DisplayName ?? bl.ModId}</strong>");
|
||||||
|
if (!string.IsNullOrEmpty(bl.Reason))
|
||||||
|
sb.Append($" <font color=\"#cc6666\">— {bl.Reason}</font>");
|
||||||
|
sb.Append("<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append("<br><font color=\"#cc6666\">Bitte entferne diese Mods um auf diesem Server zu spielen.</font><br>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WelcomePacket>(OnWelcomeReceived);
|
||||||
|
channel.SetMessageHandler<ModCheckResultPacket>(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<ClientModInfo>();
|
||||||
|
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<RecommendedModEntry>
|
||||||
|
{
|
||||||
|
new RecommendedModEntry
|
||||||
|
{
|
||||||
|
ModId = "rpvoicechat",
|
||||||
|
DisplayName = "RP Voice Chat",
|
||||||
|
ModDbUrl = "https://mods.vintagestory.at/rpvoicechat",
|
||||||
|
Reason = "Proximity Voice Chat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
BlacklistedMods = new List<BlacklistedModEntry>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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.*";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user