rebuild the Zomboid server on the Sophie 42 preset as "debbzoid"

The deployment had drifted badly. It was pinned to the Steam `-beta unstable`
branch, which stopped being right the moment B42 became the default at 42.20,
and it wrote every mod ID with the `\` prefix that B42 required only during
that unstable period and now rejects. Three server profiles were carried
around (vanilla, modded, b42revamp) whose mod lists were hand-curated blobs
from a Steam collection that has since moved on.

Moves to the Sophie 42 community preset, vendored from GerDeathstar/sophie-pz
("Sophie 42 Files.zip", 2026-07-31 == the b42 release tag): 283 mod IDs, 256
workshop items, map_distanciado over Muldraugh, plus its SandboxVars,
spawnregions and spawnpoints. Sophie's gameplay settings are kept exactly as
shipped -- PVP with its damage modifiers, the safety system, MaxPlayers=32,
PauseEmpty, no sleep, safehouses off. Only the keys this deployment actually
owns are templated over the top.

The modlist is stored as YAML lists rather than the INI's semicolon blobs, so
the next Sophie update produces a diff you can read. Exclusions live in
zomboid_mods_excluded with a reason each, seeded with IconsInventory -- already
absent upstream, listed so a re-vendor cannot quietly bring it back.

Config is now seeded before first boot instead of patched after it. The old
approach could not write the INI until the server had generated one, so every
setting went through lineinfile guarded on a stat; seeding the whole file from
the preset removes the chicken-and-egg and puts the config in git. force is
off by design: these files are a starting point, not managed state, so the
server can be stopped, hand-edited and regenerated without Ansible clobbering
the edits. Push them again deliberately with -e zomboid_config_force=true.

The B41 Discord keys were dead. B42 replaced DiscordChannel/DiscordChannelID
with DiscordChatChannel, which takes a channel name rather than a snowflake,
so the DiscordChannelID=... this role had been appending was a key the server
ignores and the chat bridge has not been working.

Renaming the server to debbzoid is what starts the new world: PZ derives the
INI, save directory and player DB from the name, so gregboid stays on disk
untouched as a rollback.

Re-enabled, because the reason it was off is now fixed. It was disabled for
saturating the SSD -- ~10 MB/s of log writes into the shared journal, which
starved Gitea CI badly enough to stretch a firmware build to 17 minutes. The
container now logs to its own rotating k8s-file instead of the journal every
other service shares, and logrotate caps the server's own server-console.txt
and Logs/ at 100M keeping 3. copytruncate is mandatory there: PZ holds those
fds for its whole life, so a rename would leave it writing into an unlinked
inode. Memory is unchanged; idle draw is nowhere near MAX_RAM.

Also stops the entrypoint recursively chowning the install tree on every boot.
With 256 workshop mods that is a large inode walk and pure churn once the
first run has set ownership.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bastian de Byl
2026-08-25 23:41:00 -04:00
co-authored by Claude Opus 5
parent eea55def6c
commit d34c6cd36d
15 changed files with 3427 additions and 159 deletions
@@ -3,8 +3,9 @@
# 1.9 TB root means it will happily grow into the tens of gigabytes; it had
# reached 4 GB before this was set.
#
# Every container runs with log_driver=journald, so this is where container
# stdout lands.
# Container stdout lands here: every container runs with log_driver=journald
# except zomboid, which is chatty enough to evict everything else and so gets
# its own rotating k8s-file log instead.
{% if graylog_enabled | bool %}
# fluent-bit drains the journal into Graylog continuously (systemd input,
# _COMM=conmon -- see templates/fluent-bit/fluent-bit.conf.j2), so Graylog is
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
function SpawnPoints()
return {
unemployed = {
{ worldX = 40, worldY = 22, posX = 67, posY = 201 }
}
}
end
@@ -0,0 +1,15 @@
function SpawnRegions()
return {
{ name = "Brandenburg, KY", file = "media/maps/Brandenburg, KY/spawnpoints.lua" },
{ name = "Echo Creek, KY", file = "media/maps/Echo Creek, KY/spawnpoints.lua" },
{ name = "Ekron, KY", file = "media/maps/Ekron, KY/spawnpoints.lua" },
{ name = "Fallas Lake, KY", file = "media/maps/Fallas Lake, KY/spawnpoints.lua" },
{ name = "Irvington, KY", file = "media/maps/Irvington, KY/spawnpoints.lua" },
{ name = "March Ridge, KY", file = "media/maps/March Ridge, KY/spawnpoints.lua" },
{ name = "Muldraugh, KY", file = "media/maps/Muldraugh, KY/spawnpoints.lua" },
{ name = "Riverside, KY", file = "media/maps/Riverside, KY/spawnpoints.lua" },
{ name = "Rosewood, KY", file = "media/maps/Rosewood, KY/spawnpoints.lua" },
{ name = "Valley Station, KY", file = "media/maps/Valley Station, KY/spawnpoints.lua" },
{ name = "West Point, KY", file = "media/maps/West Point, KY/spawnpoints.lua" },
}
end
@@ -1,4 +1,9 @@
---
- name: load vendored sophie modlist
ansible.builtin.include_vars:
file: zomboid_sophie_mods.yml
tags: zomboid-conf
- name: create zomboid host directory volumes
become: true
ansible.builtin.file:
@@ -122,6 +127,65 @@
mode: 0644
notify: restorecon podman
# Server config is seeded from the vendored Sophie 42 preset *before* first
# boot, so the server never generates a vanilla INI we then have to patch.
#
# force is off by design: these files are a starting point, not a managed
# state. Stop the server, hand-edit them, regenerate the world -- Ansible will
# not clobber the edits on the next deploy. Re-push deliberately with
# -e zomboid_config_force=true.
- name: create zomboid server config directory
become: true
ansible.builtin.file:
path: "{{ zomboid_path }}/data/Server"
state: directory
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0755
notify: restorecon podman
tags: zomboid-conf
- name: seed zomboid server ini from sophie preset
become: true
ansible.builtin.template:
src: zomboid/server.ini.j2
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}.ini"
force: "{{ zomboid_config_force | bool }}"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0644
notify: restorecon podman
tags: zomboid-conf
# Copied, not templated: these are Lua and must not go through Jinja.
- name: seed zomboid sandbox and spawn config from sophie preset
become: true
ansible.builtin.copy:
src: "zomboid/sophie/{{ item }}.lua"
dest: "{{ zomboid_path }}/data/Server/{{ zomboid_server_name }}_{{ item }}.lua"
force: "{{ zomboid_config_force | bool }}"
owner: "{{ podman_subuid.stdout }}"
group: "{{ podman_user }}"
mode: 0644
loop:
- SandboxVars
- spawnregions
- spawnpoints
notify: restorecon podman
tags: zomboid-conf
# The server's own logs, not the container's. PZ holds these fds open for the
# life of the process, so copytruncate is mandatory -- a rename would leave it
# writing into an unlinked inode and the file would appear to stop growing.
- name: deploy zomboid logrotate config
become: true
ansible.builtin.template:
src: zomboid/logrotate.j2
dest: /etc/logrotate.d/zomboid
owner: root
group: root
mode: 0644
# Set volume permissions for steam user (UID 1000) inside container
# This uses podman unshare to set ownership correctly for rootless podman
- name: set zomboid volume permissions for steam user
@@ -147,11 +211,13 @@
name: zomboid
image: "{{ image }}"
restart_policy: on-failure:3
log_driver: journald
log_driver: k8s-file
log_opt:
max_size: 50m
env:
SERVER_NAME: "{{ zomboid_server_names[zomboid_server_mode] }}"
MIN_RAM: 8g
MAX_RAM: 24g
SERVER_NAME: "{{ zomboid_server_name }}"
MIN_RAM: "{{ zomboid_min_ram }}"
MAX_RAM: "{{ zomboid_max_ram }}"
AUTO_UPDATE: "true"
ADMIN_PASSWORD: "{{ zomboid_admin_password }}"
SERVER_PASSWORD: "{{ zomboid_password }}"
@@ -183,102 +249,6 @@
line: "Restart=always"
notify: reload zomboid systemd
# Check if server INI exists (generated on first server run)
- name: check if zomboid server ini exists
become: true
ansible.builtin.stat:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
register: zomboid_ini_stat
tags: zomboid-conf
# Backup settings (requires server to have run once to generate ini)
- name: configure zomboid backup settings
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "SaveWorldEveryMinutes", value: "10" }
- { key: "BackupsPeriod", value: "30" }
- { key: "BackupsCount", value: "10" }
# B42 Linux server fix: disable Lua checksum to allow mods to load
- { key: "DoLuaChecksum", value: "false" }
# Server password
- { key: "Password", value: "{{ zomboid_password }}" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Discord integration (uses Gregbot token, posts /all chat to Discord)
- name: configure zomboid discord integration
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "DiscordEnable", value: "true" }
- { key: "DiscordToken", value: "{{ zomboid_discord_token }}" }
- { key: "DiscordChannel", value: "zomboidbot" }
- { key: "DiscordChannelID", value: "1451961291194241095" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# RCON configuration for remote administration
- name: configure zomboid rcon
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "RCONPort", value: "{{ zomboid_rcon_port }}" }
- { key: "RCONPassword", value: "{{ zomboid_admin_password }}" }
when: zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Mod configuration for 'modded' server profile
- name: configure zomboid mods for modded server
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "Mods", value: "{{ zomboid_mods.mod_ids }}" }
- { key: "WorkshopItems", value: "{{ zomboid_mods.workshop_items }}" }
when:
- zomboid_server_mode == 'modded'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Mod configuration for 'b42revamp' server profile (168 mods from Steam collection)
- name: configure zomboid mods for b42revamp server
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop:
- { key: "Mods", value: "{{ zomboid_mods_b42revamp.mod_ids }}" }
- { key: "WorkshopItems", value: "{{ zomboid_mods_b42revamp.workshop_items }}" }
when:
- zomboid_server_mode == 'b42revamp'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Map configuration (for modded servers with custom maps)
- name: configure zomboid map
become: true
ansible.builtin.lineinfile:
path: "{{ zomboid_path }}/data/Server/{{ zomboid_server_names[zomboid_server_mode] }}.ini"
regexp: "^Map="
line: "Map={{ zomboid_maps[zomboid_server_mode] }}"
when:
- zomboid_server_mode != 'vanilla'
- zomboid_ini_stat.stat.exists
tags: zomboid-conf
# Firewall logging for player IP correlation
# Logs new UDP connections to Zomboid port for IP address tracking
- name: add firewall rule to log zomboid connections
+2 -1
View File
@@ -126,7 +126,8 @@
image: localhost/greg-time-bot:3.14.1
tags: gregtime
# Gated off by default — see zomboid_enabled in roles/podman/defaults/main.yml.
# Gated on zomboid_enabled (roles/podman/defaults/main.yml) so it can be taken
# down for a CI-heavy stretch without losing the world.
- import_tasks: containers/home/zomboid.yml
vars:
image: docker.io/cm2network/steamcmd:root
@@ -10,10 +10,10 @@ STEAMCMD="/home/steam/steamcmd/steamcmd.sh"
SERVER_NAME="${SERVER_NAME:-zomboid}"
PUID="${PUID:-1000}"
PGID="${PGID:-1000}"
MIN_RAM="${MIN_RAM:-4g}"
MAX_RAM="${MAX_RAM:-8g}"
MIN_RAM="${MIN_RAM:-{{ zomboid_min_ram }}}"
MAX_RAM="${MAX_RAM:-{{ zomboid_max_ram }}}"
echo "=== Project Zomboid Build 42 Server ==="
echo "=== Project Zomboid Build 42 Server ({{ zomboid_preset_version }}) ==="
echo "Server Name: ${SERVER_NAME}"
echo "RAM: ${MIN_RAM} - ${MAX_RAM}"
@@ -21,7 +21,15 @@ echo "RAM: ${MIN_RAM} - ${MAX_RAM}"
echo "=== Setting file permissions ==="
usermod -o -u "${PUID}" steam
groupmod -o -g "${PGID}" steam
chown -R steam:steam "${INSTALL_DIR}" "${CONFIG_DIR}"
for dir in "${INSTALL_DIR}" "${CONFIG_DIR}"; do
# Recursing is a full inode walk over the install tree -- with 256 workshop
# mods that is real SSD churn on every restart, and a no-op after the first.
if [ "$(stat -c %u "${dir}")" = "${PUID}" ]; then
echo "=== ${dir} already owned by ${PUID}, skipping recursive chown ==="
else
chown -R steam:steam "${dir}"
fi
done
# Only chown writable parts of /home/steam (not read-only mounts)
chown steam:steam /home/steam
chown -R steam:steam /home/steam/steamcmd 2>/dev/null || true
@@ -32,9 +40,9 @@ mkdir -p "${CONFIG_DIR}/Server"
mkdir -p "${CONFIG_DIR}/Saves"
mkdir -p "${CONFIG_DIR}/db"
# Update/Install PZ dedicated server with Build 42 unstable branch
# Update/Install PZ dedicated server from the default (stable) branch
if [ "${AUTO_UPDATE:-true}" = "true" ]; then
echo "=== Updating Project Zomboid Server (Build 42 unstable) ==="
echo "=== Updating Project Zomboid Server (Build 42 stable) ==="
su -c "${STEAMCMD} +runscript /home/steam/install.scmd" steam
echo "=== Update complete ==="
fi
@@ -12,7 +12,8 @@ force_install_dir /project-zomboid
login anonymous
// Install/Update the Project Zomboid Dedicated Server - Unstable Branch (Build 42)
app_update 380870 -beta unstable validate
// Install/Update the Project Zomboid Dedicated Server (Build 42, stable)
// No -beta: B42 became the default branch at 42.20.
app_update 380870 validate
quit
@@ -0,0 +1,23 @@
# {{ ansible_managed }}
#
# Project Zomboid's own log files. These are separate from the container's
# stdout, which podman rotates itself via the k8s-file driver's max_size.
#
# copytruncate is mandatory here: the server holds these fds open for the whole
# life of the process, so a rename would leave it writing into an unlinked
# inode and the file would appear to simply stop growing.
#
# The Discord bot tails server-console.txt from a byte offset and resets that
# offset when the file shrinks, so a truncate under it is safe.
{{ zomboid_path }}/data/server-console.txt
{{ zomboid_path }}/data/Logs/*.txt
{
copytruncate
daily
size 100M
rotate 3
compress
delaycompress
missingok
notifempty
}
@@ -0,0 +1,439 @@
{#-
Project Zomboid server config -- Sophie 42 preset.
Vendored verbatim from https://github.com/GerDeathstar/sophie-pz
("Sophie 42 Files.zip", uploaded 2026-07-31 == the `b42` release tag),
file Server/sophie-server-42-1.ini.
Only the keys this deployment actually owns are templated -- credentials,
RCON, Discord, backup cadence, and the mod lists. Every gameplay setting
(PVP, safety system, safehouses, sleep, MaxPlayers, PauseEmpty, ResetID,
the welcome message) is left exactly as the preset author shipped it.
Seeded once, then left alone: the deploy task uses force=false so the
server and admins can hand-edit this file between world regenerations.
Re-push it deliberately with -e zomboid_config_force=true.
-#}
{% set excluded_mod_ids = zomboid_mods_excluded | map(attribute='mod_id') | list %}
{% set excluded_workshop_ids = zomboid_mods_excluded | map(attribute='workshop_id') | list %}
# Players can hurt and kill other players
PVP=true
# PVP is logged to admin chat
PVPLogToolChat=true
# PVP is logged to file
PVPLogToolFile=true
# Game time stops when there are no players online
PauseEmpty=true
# Toggles global chat on or off.
GlobalChat=true
ChatStreams=s,r,a,w,y,sh,f,all
# Clients may join without already having an account in the whitelist. If set to false, administrators must manually create username/password combos.
Open=true
# The first welcome message visible in the chat panel. This will be displayed immediately after player login. you can use RGB colours to chance the colour of the welcome message. You can also use < LINE>, without the space, to create a separate lines within your text. Use: \<RGB:1,0,0> This message will show up red!
ServerWelcomeMessage=Welcome to Project Zomboid Multiplayer! <LINE> <LINE> To interact with the Chat panel: press Tab, T, or Enter. <LINE> <LINE> The Tab key will change the target stream of the message. <LINE> <LINE> Global Streams: /all <LINE> Local Streams: /say, /yell <LINE> Special Steams: /whisper, /safehouse, /faction. <LINE> <LINE> Press the Up arrow to cycle through your message history. Click the Gear icon to customize chat. <LINE> <LINE> Happy surviving!
# Display usernames above player's heads in-game.
DisplayUserName=true
# Display first & last name above player's heads.
ShowFirstAndLastName=false
UsernameDisguises=false
HideDisguisedUserName=false
SwitchZombiesOwnershipEachUpdate=false
# Force every new player to spawn at these set x,y,z world coordinates. Find desired coordinates at map.projectzomboid.com. (Ignored when 0,0,0)
SpawnPoint=0,0,0
# Players can enter and leave PVP on an individual basis. A player can only hurt another player when at least one of them is in PVP mode - as shown by the unobscured skull and crossbones on the left of the screen. When SafetySystem=false, players are free to hurt each other at any time if PVP is enabled.
SafetySystem=true
# Display a skull icon over the head of players who have entered PVP mode
ShowSafety=true
# The time it takes for a player to enter and leave PVP mode Min: 0 Max: 1000 Default: 2
SafetyToggleTimer=2
# The delay before a player can enter or leave PVP mode again, having recently done so Min: 0 Max: 1000 Default: 3
SafetyCooldownTimer=3
# Min: 0 Max: 60 Default: 60
SafetyDisconnectDelay=60
# Item types new players spawn with. Separate multiple item types with commas. Example: Base.Axe,Base.Bag_BigHikingBag
SpawnItems=
# Default starting port for player data. If UDP, this is this one of two ports used. Min: 0 Max: 65535 Default: 16261
DefaultPort=16261
# Min: 0 Max: 65535 Default: 16262
UDPPort=16262
# Reset ID determines if the server has undergone a soft-reset. If this number does match the client, the client must create a new character. Used in conjunction with PlayerServerID. It is strongly advised that you backup these IDs somewhere Min: 0 Max: 2147483647 Default: 985847558
ResetID=6953472
# Enter the mod loading ID here. It can be found in \Steam\steamapps\workshop\modID\mods\modName\info.txt
Mods={{ zomboid_sophie_mod_ids | reject('in', excluded_mod_ids) | join(';') }}
# Enter the foldername of the mod found in \Steam\steamapps\workshop\modID\mods\modName\media\maps\
Map={{ zomboid_map }}
# Kick clients whose game files don't match the server's.
DoLuaChecksum=false
DenyLoginOnOverloadedServer=true
# Shows the server on the in-game browser. (Note: Steam-enabled servers are always visible in the Steam server browser)
Public=false
# Name of the server displayed in the in-game browser and, if applicable, the Steam browser
PublicName={{ zomboid_public_name }}
# Description displayed in the in-game public server browser. Typing will create a new line in your description
PublicDescription=
# Maximum number of players that can be on the server at one time. This excludes admins.
# WARNING: Server player counts above 32 will potentially result in poor map streaming and desync. Please advance with caution. Min: 1 Max: 100 Default: 32
MaxPlayers=32
# Ping limit, in milliseconds, before a player is kicked from the server. (Set to 0 to disable) Min: 0 Max: 2147483647 Default: 0
PingLimit=0
# Items will not respawn in buildings that players have claimed as a safehouse
SafehousePreventsLootRespawn=true
# Remove player accounts from the whitelist after death. This prevents players creating a new character after death on Open=false servers
DropOffWhiteListAfterDeath=false
# All forms of fire are disabled - except for campfires
NoFire=false
# If checked, every time a player dies a global message will be displayed in the chat
AnnounceDeath=true
# If checked, every time an animal dies a global message will be displayed in the chat
AnnounceAnimalDeath=false
# Loaded parts of the map are saved after this set number of real-world minutes have passed. (The map is usually saved only after clients leave a loaded area) Min: 0 Max: 2147483647 Default: 0
SaveWorldEveryMinutes=10
# Both admins and players can claim safehouses
PlayerSafehouse=false
# Only admins can claim safehouses
AdminSafehouse=false
# Allow non-members to enter a safehouse without being invited
SafehouseAllowTrepass=true
# Allow fire to damage safehouses
SafehouseAllowFire=true
# Allow non-members to take items from safehouses
SafehouseAllowLoot=true
# Players will respawn in a safehouse that they were a member of before they died
SafehouseAllowRespawn=false
# Players must have survived this number of in-game days before they are allowed to claim a safehouse Min: 0 Max: 2147483647 Default: 0
SafehouseDaySurvivedToClaim=0
# Players are automatically removed from a safehouse they have not visited for this many real-world hours Min: 0 Max: 2147483647 Default: 144
SafeHouseRemovalTime=144
# Governs whether players can claim non-residential buildings.
SafehouseAllowNonResidential=false
SafehouseDisableDisguises=true
# Min: 0 Max: 2147483647 Default: 20000
MaxSafezoneSize=20000
# Allow players to destroy world objects with sledgehammers
AllowDestructionBySledgehammer=true
# Allow players to destroy world objects only in their safehouse (require AllowDestructionBySledgehammer to true).
SledgehammerOnlyInSafehouse=false
War=false
# Time in seconds before the war starts. Min: 60 Max: 2147483647 Default: 600
WarStartDelay=600
# War duration in seconds. Min: 60 Max: 2147483647 Default: 3600
WarDuration=3600
# Safehouse hit points limit. Min: 0 Max: 2147483647 Default: 3
WarSafehouseHitPoints=3
# ServerPlayerID determines if a character is from another server, or single player. This value may be changed by soft resets. If this number does match the client, the client must create a new character. This is used in conjunction with ResetID. It is strongly advised that you backup these IDs somewhere
ServerPlayerID=
# The port for the RCON (Remote Console) Min: 0 Max: 65535 Default: 27015
RCONPort={{ zomboid_rcon_port }}
# RCON password (Pick a strong password)
RCONPassword={{ zomboid_admin_password }}
# Enables global text chat integration with a Discord channel
DiscordEnable=true
# Discord bot access token
DiscordToken={{ zomboid_discord_token }}
# The Discord chat channel name
DiscordChatChannel={{ zomboid_discord_chat_channel }}
# The Discord logs channel name
DiscordLogChannel=
# The Discord commands channel name
DiscordCommandChannel=
# The Slack incoming webhook URL
WebhookAddress=
# Clients must know this password to join the server. (Ignored when hosting a server via the Host button)
Password={{ zomboid_password }}
# Limits the number of different accounts a single Steam user may create on this server. Ignored when using the Hosts button. Min: 0 Max: 2147483647 Default: 0
MaxAccountsPerUser=0
# Allow co-op/splitscreen players
AllowCoop=true
# Players are allowed to sleep when their survivor becomes tired, but they do not NEED to sleep
SleepAllowed=false
# Players get tired and need to sleep. (Ignored if SleepAllowed=false)
SleepNeeded=false
# WIP: Activating this setting may cause visual desynchronization of player positions.
KnockedDownAllowed=false
SneakModeHideFromOtherPlayers=true
UltraSpeedDoesnotAffectToAnimals=false
# List Workshop Mod IDs for the server to download. Each must be separated by a semicolon. Example: WorkshopItems=514427485;513111049
WorkshopItems={{ zomboid_sophie_workshop_items | reject('in', excluded_workshop_ids) | join(';') }}
# Show Steam usernames and avatars in the Players list.
SteamScoreboard=false
# Enable the Steam VAC system
SteamVAC=true
# Attempt to configure a UPnP-enabled internet gateway to automatically setup port forwarding rules. The server will fall back to default ports if this fails
UPnP=true
# VOIP is enabled when checked
VoiceEnable=true
# The minimum tile distance over which VOIP sounds can be heard. Min: 0.00 Max: 100000.00 Default: 10.00
VoiceMinDistance=10.0
# The maximum tile distance over which VOIP sounds can be heard. Min: 0.00 Max: 100000.00 Default: 100.00
VoiceMaxDistance=100.0
# Toggle directional audio for VOIP
Voice3D=true
# Min: 10.00 Max: 150.00 Default: 70.00
SpeedLimit=70.0
LoginQueueEnabled=false
# Min: 20 Max: 1200 Default: 60
LoginQueueConnectTimeout=60
# Set the IP from which the server is broadcast. This is for network configurations with multiple IP addresses, such as server farms
server_browser_announced_ip=
# Players can respawn in-game at the coordinates where they died
PlayerRespawnWithSelf=false
# Players can respawn in-game at a split screen / Remote Play player's location
PlayerRespawnWithOther=false
# Governs how fast time passes while players sleep. Value multiplies the speed of the time that passes during sleeping. Min: 1.00 Max: 100.00 Default: 40.00
FastForwardMultiplier=40.0
# Safehouse acts like a normal house if an owner of the safehouse is connected (so secure when the owner is offline)
DisableSafehouseWhenOwnerConnected=false
# Players can create factions when true
Faction=true
# Players must survive this number of in-game days before being allowed to create a faction Min: 0 Max: 2147483647 Default: 0
FactionDaySurvivedToCreate=0
# Number of players required as faction members before the faction owner can create a group tag Min: 1 Max: 2147483647 Default: 1
FactionPlayersRequiredForTag=1
# Disables radio transmissions from players with an access level
DisableRadioStaff=false
# Disables radio transmissions from players with 'admin' access level
DisableRadioAdmin=true
# Disables radio transmissions from players with 'gm' access level
DisableRadioGM=true
# Disables radio transmissions from players with 'overseer' access level
DisableRadioOverseer=false
# Disables radio transmissions from players with 'moderator' access level
DisableRadioModerator=false
# Disables radio transmissions from invisible players
DisableRadioInvisible=true
# Semicolon-separated list of commands that will not be written to the cmd.txt server log. For example: -vehicle. Inputting * means do NOT write any vehicle command. Inputting: +vehicle.installPart means DO write that command
ClientCommandFilter=-vehicle.*;+vehicle.damageWindow;+vehicle.fixPart;+vehicle.installPart;+vehicle.uninstallPart
# Semicolon-separated list of actions that will be written to the ClientActionLogs.txt server log.
ClientActionLogs=ISEnterVehicle;ISExitVehicle;ISTakeEngineParts;
# Track changes in player perk levels in PerkLog.txt server log
PerkLogs=true
# Maximum number of items that can be placed in a container. Zero means there is no limit. (PLEASE NOTE: This includes individual small items such as nails. A limit of 50 will mean only 50 nails can be stored.) Min: 0 Max: 9000 Default: 0
ItemNumbersLimitPerContainer=0
# Number of days before old blood splats are removed.
# Removal happens when map chunks are loaded.
# Zero means they will never disappear Min: 0 Max: 365 Default: 0
BloodSplatLifespanDays=0
# Allow use of non-ASCII (cyrillic etc) characters in usernames
AllowNonAsciiUsername=false
BanKickGlobalSound=true
# If enabled, when HoursForCorpseRemoval triggers, it will also remove player's corpses from the ground.
RemovePlayerCorpsesOnCorpseRemoval=false
# If true, player can use the "delete all" button on bins.
TrashDeleteAll=false
# If true, player can hit again when struck by another player.
PVPMeleeWhileHitReaction=false
# If true, players will have to mouse over someone to see their display name.
MouseOverToSeeDisplayName=true
# If true, automatically hide the player you can't see (like zombies).
HidePlayersBehindYou=true
# Damage multiplier for PVP melee attacks. Min: 0.00 Max: 500.00 Default: 30.00
PVPMeleeDamageModifier=30.0
# Damage multiplier for PVP ranged attacks. Min: 0.00 Max: 500.00 Default: 50.00
PVPFirearmDamageModifier=50.0
# Modify the range of zombie attraction to cars. (Lower values can help with lag.) Min: 0.00 Max: 10.00 Default: 0.50
CarEngineAttractionModifier=0.5
# Governs whether players bump (and knock over) other players when running through them.
PlayerBumpPlayer=false
# Controls display of remote players on the in-game map. 1=Hidden 2=Friends 3=Friends and nearby players 4=Everyone Min: 1 Max: 4 Default: 1
MapRemotePlayerVisibility=1
# Min: 1 Max: 300 Default: 5
BackupsCount=10
BackupsOnStart=true
BackupsOnVersionChange=true
# Min: 0 Max: 1500 Default: 0
BackupsPeriod=30
# Disables vehicle towing
DisableVehicleTowing=false
# Disables trailer towing
DisableTrailerTowing=false
# Disables burnt vehicle towing
DisableBurntTowing=false
# Path to the file with the list of words to be prohibited. Each word must be on a separate line
BadWordListFile=
# Path to the file with the list of words that should be allowed, even if they contain bad word. Each word should be on a separate line.
GoodWordListFile=
# What to do with the sender of a bad word in the chat: 1 - ban, 2 - kick, 3 - record the violation in the database, 4 - mute
BadWordPolicy=3
# The symbol or piece of text that will replace the bad word
BadWordReplacement=[HIDDEN]
# Disables safety system anti-cheat protection.
AntiCheatSafety=2
# Disables character speed anti-cheat protection.
AntiCheatSpeed=2
# Disables character no-clipping anti-cheat protection.
AntiCheatNoClip=4
# Disables character hit anti-cheat protection.
AntiCheatHit=2
# Disables packet exception checks anti-cheat protection.
AntiCheatPacketException=4
# Disables player permissions anti-cheat protection.
AntiCheatPermission=2
# Disables player XP anti-cheat protection.
AntiCheatXP=2
# Disables safehouse anti-cheat protection.
AntiCheatSafeHouse=2
# Disables player anti-cheat protection.
AntiCheatPlayer=2
# Disables checksum anti-cheat protection.
AntiCheatChecksum=2
# Sets the multiplayer update period in seconds. Statistics is disabled if value is 0. Min: 0 Max: 10 Default: 1
MultiplayerStatisticsPeriod=1
# Disables scoreboard.
DisableScoreboard=false
# Hides admins in the player list.
HideAdminsInPlayerList=false
# Sets the limit for processing network packets from each client on the server. Min: 100 Max: 1000 Default: 300
MaxPacketsPerSecond=300
# Shows player character coordinates in the lower right corner.
ShowCoordinates=false
# The worldgen seed used to generate the world.
# If you want to change this, put a new value in and delete map_worldgen.bin in your save directory.
Seed=DMCERZSTKtuzgukR
UsePhysicsHitReaction=false
# Min: 64 Max: 1024 Default: 200
ChatMessageCharacterLimit=200
# Min: 1 Max: 30 Default: 3
ChatMessageSlowModeTime=3
@@ -6,7 +6,7 @@ set -e
LOGFILE="{{ podman_home }}/.local/share/volumes/zomboid/logs/world-reset.log"
TRIGGER_FILE="{{ podman_home }}/.local/share/volumes/gregtime/data/zomboid-reset.trigger"
SERVER_NAME="{{ zomboid_server_names[zomboid_server_mode] }}"
SERVER_NAME="{{ zomboid_server_name }}"
SAVES_PATH="{{ podman_home }}/.local/share/volumes/zomboid/data/Saves/Multiplayer/${SERVER_NAME}"
DB_PATH="{{ podman_home }}/.local/share/volumes/zomboid/data/db/${SERVER_NAME}.db"
@@ -0,0 +1,562 @@
---
# Sophie 42 modlist -- vendored verbatim from the upstream server preset.
#
# Source: https://github.com/GerDeathstar/sophie-pz ("Sophie 42 Files.zip",
# uploaded 2026-07-31, identical to the `b42` release tag)
# Origin: Server/sophie-server-42-1.ini, keys `Mods=` and `WorkshopItems=`
#
# Kept as lists rather than the semicolon blobs the INI uses so that a Sophie
# update produces a reviewable diff. `templates/zomboid/server.ini.j2` joins
# them back with `;` after subtracting `zomboid_mods_excluded`.
#
# ORDER IS SIGNIFICANT for zomboid_sophie_mod_ids -- it is the mod load order.
# Preserve upstream ordering exactly when re-vendoring; do not sort.
#
# No `\` prefix: Build 42 required one only during its unstable period and
# rejects it on 42.20+.
# Mod IDs (from each package's mod.info) -- the `Mods=` server key.
zomboid_sophie_mod_ids:
- "ModLoadOrderSorter_b42"
- "damnlib"
- "YAPZLib"
- "ChuckleberryFinnAlertSystem"
- "errorMagnifier"
- "ZomboidPaperAPI"
- "MoodleFramework"
- "moodlesinlua"
- "NeatUI_Framework"
- "HBVCEFb42"
- "KATTAJ1_ClothesCore"
- "SpnOpenClothBase"
- "TombBody"
- "isoContainers"
- "PROJECTRVInterior42"
- "LargeProgressIndicator"
- "CleanUI"
- "MiniHealthPlus"
- "BravoItemDetails"
- "DetailedDescriptionsForOccupationsAndTraits"
- "MutiesContextMenuIcons"
- "ItemCondition_KingEJ"
- "SPNCC"
- "SPNCCDetails"
- "SPNCCDetailsHD"
- "SeedSeasonIndicator"
- "NeatUI_Hairstyler"
- "Neat_Building"
- "Neat_Building_AddonXP"
- "Neat_Crafting"
- "Neat_Crafting_AddonXP"
- "DynamicMoodles"
- "devcattpixelmoodles"
- "redrawnmoodles"
- "PMResurrection"
- "PlainMoodles_by_Slobodskoy"
- "quarterbordermoodles"
- "SimpleMoodleIndicators"
- "v1centesmoodles"
- "v1centesmoodlesbeta"
- "SandboxSettings"
- "SimpleOverhaulTraitsAndOccupations"
- "BeyondTen"
- "HasResearch"
- "WorkingKnowledge"
- "SkillRecoveryJournal"
- "M13ReadingTweaks"
- "P4HasBeenRead"
- "P4PickingMeister"
- "P4TidyUpMeister"
- "KillCount"
- "CausaMortis"
- "KI5campers"
- "KI5trailers"
- "TradingTrailer"
- "49powerWagon"
- "59meteor"
- "63beetle"
- "63Type2Van"
- "65banshee"
- "66pontiacLeMans"
- "67commando"
- "67gt500"
- "68firebird"
- "69camaro"
- "69charger"
- "69fordMustang"
- "69fordMustangExtra"
- "69mini"
- "69mini_ItalianJob"
- "69mini_PitbullSpecial"
- "70barracuda"
- "70chevelle"
- "70dodge"
- "70fordEscort"
- "70roadRunner"
- "73fordFalcon"
- "73fordFalconPS"
- "73nissanGTR"
- "75grandPrix"
- "76chevyKseries"
- "76chryslerNewYorker"
- "77firebird"
- "78amgeneralM35A2"
- "78amgeneralM35A2extra"
- "78amgeneralM49A2C"
- "78amgeneralM50A3"
- "78amgeneralM62"
- "78lamboCountach"
- "79camaro"
- "80manKat1"
- "81deloreanDMC12"
- "82firebird"
- "82jeepJ10"
- "82jeepJ10t"
- "82oshkoshM911"
- "82porsche911"
- "84buickElectra"
- "84cadillacDeVille"
- "84corvette"
- "84jeepXJ"
- "84merc"
- "84oldsmobile98"
- "85buickLeSabre"
- "85chevyCaprice"
- "85chevyStepVan"
- "85chevyStepVanexpanded"
- "85oldsmobileDelta88"
- "85pontiacParisienne"
- "86chevyCUCV"
- "86fordE150"
- "86fordE150expanded"
- "86fordE150pd"
- "86oshkoshP19A"
- "87buickRegal"
- "87chevySuburban"
- "87fordB700"
- "87toyotaMR2"
- "88chevyS10"
- "88toyotaHilux"
- "89defender"
- "89dodgeCaravan"
- "89fordBronco"
- "89trooper"
- "89volvo200"
- "90bmwE30"
- "90fordF350ambulance"
- "90pierceArrow"
- "91fordLTD"
- "91fordRanger"
- "91geoMetro"
- "91lexusLS400"
- "91nissan240sx"
- "91range"
- "92amgeneralM998"
- "92amgeneralM998extra"
- "92fordCVPI"
- "92jeepYJ"
- "92jeepYJJP18"
- "92nissanGTR"
- "93chevySuburban"
- "93chevySuburbanExpanded"
- "93fordElgin"
- "93fordF350"
- "93fordTaurus"
- "93mustangSSP"
- "93townCar"
- "99fordCVPI"
- "ReactiveSE"
- "HereGoesTheSun"
- "RainCleansBlood"
- "CheckthePapers"
- "DeadMansDossier"
- "KnoxDetectionKit"
- "ZVirusVaccine42BETA"
- "LifestyleHobbies"
- "GuitarMusicAddon"
- "DeltaSeries"
- "AP"
- "AutoReload"
- "AutoTailoring"
- "better-auto-mechanics"
- "CaffeineMakesSense"
- "Drink_Smart"
- "dustinguished_bolt_cutters"
- "FasterHoodOpening"
- "FWOFitnessWorkoutOverhaul"
- "FWOBenchPress&Treadmill"
- "KeepRadioOnVanillaFriendly"
- "MoreUsesForLightersAndMatches"
- "NoLighterNeeded"
- "OCsPacking"
- "SmarterStorage"
- "STA_PryOpen"
- "stanks_suicide"
- "SwapIt"
- "throw-your-bag-across"
- "UseItemsWhileWalkingB42"
- "OpenAllContainers"
- "RebalancedPropMoving"
- "RepairAnyClothes"
- "ReducedWeightWalkieTalkies"
- "WashFix"
- "w_betterwringing"
- "DRAW_ON_MAP"
- "FirstAidVHSTapes"
- "NepCanteenSlot"
- "TacHold AR"
- "TacHold Pistol AR"
- "ShelterHold_Beehive"
- "Ladders42131"
- "JWJ"
- "ClothingLine"
- "ComputerModkum"
- "Buttstroke"
- "CVI"
- "CVLS"
- "FunctionalAppliances2"
- "JB_BugsInLights"
- "LWBetterElectronics"
- "MoreDamagedObjects"
- "PIP"
- "playable_pool"
- "PSR"
- "RepairWallCracks_B42"
- "SomewhatWater"
- "Snowman"
- "SkillBookExpansionB42"
- "UseulTents"
- "WorkingVending"
- "Video_Game_Consoles"
- "VGC_Addon_NESGames"
- "VGC_Addon_SEGAGames"
- "VGC_Addon_SNESGames"
- "VGC_Addon_SNESGames2"
- "TVRadio_ReInvented"
- "NewMusic"
- "NewMusicG70s"
- "NewMusicG80s"
- "NewMusicG90s"
- "NMGreatestHits"
- "NewMusic_OrchestraMix"
- "Project_Cook"
- "B42Horticulture"
- "SKITTLE_LongTermPreservation4220"
- "VanillaFoodsExpanded"
- "SapphCooking_B42"
- "Makedriedfruit"
- "Makedriedvegetable"
- "Makefruitinjar"
- "Mushroomjar"
- "ServingPlatesB42"
- "Edwin'sCannedTent"
- "Edwin'sCannedTobaccoKit"
- "Edwin'sSurvivalTabsMod"
- "dustinguished_cleaning_wipes"
- "ExtraBombs2"
- "ammomaker"
- "B42Survival"
- "SWMG"
- "MarzGuns"
- "LEGION18"
- "SpnOpenCloth"
- "SpnCloth"
- "FH"
- "SpnHair"
- "VanillaGearExpanded"
- "ArmoredVests"
- "ArmoredVestsPatch"
- "AliceGear"
- "[J&G] Firefighter Uniform"
- "[J&G] SWAT Uniform"
- "[J&G] Trauma Responder Uniform"
- "[J&G] Desert Multicam Uniform"
- "DarkWpnSlings"
- "GanydeBielovzki's Frockin Splendor!"
- "GanydeBielovzki's Frockin Splendor! Vol.2"
- "GanydeBielovzki's Frockin Splendor! Vol.3"
- "GanydeBielovzki's Frockin Splendor! Vol.4"
- "GanydeBielovzki's Frockin Splendor! Vol.5"
- "GanydeBielovzki's Frockin Shirts n Ties"
- "GanydeBielovzki's Frockin Wiseguys"
- "GanydeBielovzki's Frockin Stompers! VFR"
- "CorvusNVG"
- "AccCamos"
- "AccCamosPeriodPack"
- "TombBodyCompat"
- "TombBodyTex"
- "TombBodyCustom"
- "ProjectArcade"
- "BCR"
- "DG_MIVehicles"
- "WayMoreCars"
- "VMZNEW"
- "VVR"
- "RealisticDash"
- "GasPumpIndicator"
- "immersive_hotwire"
- "NepHighBeams"
- "TaillightsAndStoplights"
- "VehicleDoorsHotkey"
- "VehicleRepairOverhaul"
- "VehicleSalvageOverhaulB42"
# Numeric Steam Workshop page IDs -- the `WorkshopItems=` server key.
# These are what SteamCMD actually downloads.
zomboid_sophie_workshop_items:
- "3423660713"
- "3171167894"
- "3624971238"
- "3077900375"
- "2896041179"
- "3155647374"
- "3396446795"
- "3395171770"
- "3508537032"
- "3610677934"
- "3470422050"
- "2812326159"
- "3429790870"
- "2625625421"
- "3543229299"
- "2926455909"
- "3437629766"
- "3710913197"
- "3477237184"
- "3387957272"
- "3101668503"
- "3641048285"
- "3414634809"
- "3734639991"
- "3760534563"
- "3536052310"
- "3540503606"
- "3502080466"
- "3674800555"
- "3417051694"
- "3386486141"
- "3569718104"
- "3772979228"
- "3391323469"
- "3402171609"
- "3389609367"
- "3386906181"
- "2840805724"
- "3765241705"
- "3567695139"
- "3717099183"
- "2503622437"
- "2776874515"
- "2544353492"
- "3422220305"
- "2769706949"
- "2553809727"
- "3711044244"
- "3670064951"
- "3330403100"
- "3572122233"
- "2900580391"
- "2772575623"
- "3005903549"
- "3041122351"
- "3566868353"
- "3447272250"
- "2478247379"
- "3026723485"
- "3258343790"
- "2991201484"
- "3631989559"
- "3756938756"
- "2937786633"
- "2913633066"
- "3766571591"
- "2873290424"
- "3670063857"
- "3642935062"
- "3490370700"
- "3743371090"
- "3213391371"
- "3161951724"
- "3730833846"
- "3346905070"
- "2799152995"
- "3726526329"
- "3703948448"
- "3248388837"
- "3253385114"
- "3320947974"
- "2886832257"
- "2618213077"
- "3379334330"
- "3596903773"
- "3592777775"
- "3684254299"
- "3409287192"
- "2805630347"
- "3601417745"
- "3418252689"
- "3413704851"
- "3614034284"
- "3418253716"
- "3413706334"
- "3428008364"
- "2870394916"
- "2566953935"
- "3226885926"
- "3196180339"
- "3110911330"
- "3052360250"
- "2886832936"
- "3435796523"
- "3570973322"
- "3034636011"
- "2886833398"
- "2932549988"
- "3292659291"
- "3110913021"
- "2952802178"
- "2942793445"
- "3366300557"
- "3539691958"
- "3008795514"
- "3770890864"
- "3504401781"
- "2409333430"
- "2642541073"
- "2962175696"
- "3287727378"
- "2846036306"
- "3152529790"
- "2969343830"
- "3073430075"
- "3088951320"
- "3001592312"
- "2932547723"
- "2971246021"
- "3656190498"
- "3618557184"
- "2956146279"
- "3580513066"
- "3675740871"
- "3688879406"
- "3615135168"
- "3403870858"
- "3412944806"
- "3660235667"
- "3766508989"
- "3389448389"
- "3388183573"
- "3635856965"
- "3682557825"
- "3447775367"
- "3671176591"
- "2584112711"
- "2940354599"
- "3558422176"
- "3533994661"
- "2714198296"
- "3626823538"
- "3290232938"
- "3579640010"
- "3426448380"
- "2366717227"
- "3391902125"
- "3615422211"
- "3465040406"
- "2699828474"
- "2142622992"
- "2981090458"
- "3460812542"
- "3408740647"
- "2804531012"
- "3153010942"
- "3393080905"
- "3677017550"
- "3680633169"
- "3596827035"
- "3629835761"
- "2868828908"
- "3282215361"
- "3725497089"
- "3394044313"
- "3749026793"
- "3719237374"
- "3042138819"
- "3717902500"
- "2999183635"
- "3413150945"
- "3739173520"
- "3716934959"
- "3725311427"
- "3684808606"
- "3582960654"
- "3669451917"
- "3625874308"
- "2934155949"
- "2831786301"
- "3413237147"
- "3423725989"
- "3408186716"
- "3488113291"
- "3420581050"
- "3739256725"
- "3765077081"
- "3765079521"
- "3765670104"
- "3751364201"
- "3760471726"
- "3490188370"
- "3650168851"
- "3774789651"
- "3577903007"
- "3409143790"
- "3417502641"
- "3418061837"
- "3432006285"
- "3416833689"
- "3399320470"
- "3453369692"
- "3455296935"
- "3453175422"
- "3755185651"
- "3652008781"
- "2788256295"
- "3402516345"
- "3722064198"
- "3722134990"
- "3665548194"
- "3660382016"
- "3557111695"
- "2684285534"
- "2447729538"
- "2463184726"
- "3401134276"
- "1962761540"
- "3610005735"
- "3521263771"
- "3457969588"
- "3443982326"
- "3299457564"
- "3307376332"
- "3411888105"
- "3769335201"
- "3431256608"
- "3475347500"
- "3635394848"
- "3453676250"
- "3538760023"
- "3736813592"
- "3454414190"
- "3431734923"
- "3645980077"
- "3162566044"
- "3520758551"
- "3436499337"
- "3423424077"
- "3635591071"
- "3755993986"
- "3653092321"
- "3438126404"
- "3687394815"
- "2290459371"
- "2757712197"
Binary file not shown.
+15 -12
View File
@@ -15,7 +15,7 @@ Usage:
# Output formats
--json Output raw JSON data
--ansible Output workshop_items and mod_ids strings for ansible config
--ansible Output the modlist as YAML lists for vars/zomboid_sophie_mods.yml
--report Human-readable report (default)
Examples:
@@ -302,18 +302,21 @@ def output_ansible(data: Dict) -> None:
seen_mod_ids.add(mod_id)
mod_ids.append(mod_id)
# Format for Build 42 (backslash prefix)
workshop_str = ";".join(workshop_items)
mod_ids_str = ";".join(f"\\{mid}" for mid in mod_ids)
print("\n# Ansible Configuration for zomboid_mods")
print("# Copy these values to ansible/roles/podman/defaults/main.yml")
# No backslash prefix: Build 42 required one only during its unstable
# period and rejects it on 42.20+.
print("\n# Modlist for ansible/roles/podman/vars/zomboid_sophie_mods.yml")
print("# Replaces the file body wholesale -- keep the header comment.")
print("")
print("zomboid_mods:")
print(" workshop_items: >-")
print(f" {workshop_str}")
print(" mod_ids: >-")
print(f" {mod_ids_str}")
print("# Mod IDs (from each package's mod.info) -- the `Mods=` server key.")
print("# ORDER IS SIGNIFICANT: this is the mod load order. Do not sort.")
print("zomboid_sophie_mod_ids:")
for mod_id in mod_ids:
print(f" - {json.dumps(mod_id, ensure_ascii=False)}")
print("")
print("# Numeric Steam Workshop page IDs -- the `WorkshopItems=` server key.")
print("zomboid_sophie_workshop_items:")
for workshop_id in workshop_items:
print(f' - "{workshop_id}"')
if data["duplicates"]:
print("\n# WARNING: The following Mod IDs had duplicates (kept first occurrence):")