Initial commit: Welcome Screen mod v1.1.0
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:
Axel Meyer
2026-02-24 17:28:10 +00:00
commit 253ec52411
10 changed files with 754 additions and 0 deletions

View 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
View File

@@ -0,0 +1,5 @@
bin/
obj/
*.user
*.suo
.vs/

19
WelcomeScreen.csproj Normal file
View 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
View 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

Binary file not shown.

BIN
lib/protobuf-net.dll Normal file

Binary file not shown.

11
resources/modinfo.json Normal file
View 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
View 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
View 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
View 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.*";
}
}
}