feat(catalog): add vrising server + fix catalog assets
CI / validate (push) Failing after 7m51s

This commit is contained in:
2026-08-16 17:55:38 +02:00
parent efb40f2b0b
commit 62a5fdb1f7
9 changed files with 591 additions and 45 deletions
+1 -2
View File
@@ -10,7 +10,6 @@ game:
id: palworld
name: Palworld
description: DoGaMa template for a Palworld dedicated server, including the private REST API used by the optional integration.
image: https://www.codesproduit.fr/wp-content/uploads/2024/03/PALWORLD-PC-COVER.jpg
website: https://www.palworldgame.com/
artwork:
logo: assets/icon.png
@@ -42,7 +41,7 @@ container:
assets:
- source: assets/helper.sh
destination: /pal/helper.sh
sha256: 52e58fe4e22654d0d312fe2bb6195db61dad5ffce78e0440a951d33d20f2c36f
sha256: b9f30aba43886644399c7a6d40c03fec6a9f453ba6f12a881eccc1bc3de7a1d3
read_only: true
stop_timeout_seconds: 120
ports:
Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+431
View File
@@ -0,0 +1,431 @@
schema_version: 1
id: vrising-didstopia
version: 1.0.0
source:
type: verified_community
url: https://github.com/Didstopia/vrising-server
game:
id: vrising
name: V Rising
description: DoGaMa template for a V Rising dedicated server using the Didstopia Docker image.
website: https://playvrising.com/
artwork:
logo: assets/icon.png
image: assets/banner.jpg
poster: assets/poster.jpg
attribution: V Rising and related artwork are property of Stunlock Studios; no affiliation is implied.
requirements:
minimum:
cpu_cores: 2
memory_mb: 4096
storage_gb: 15
recommended:
cpu_cores: 4
memory_mb: 8192
storage_gb: 25
container:
image: didstopia/vrising-server
tag: latest
user_mode: image
stop_timeout_seconds: 120
ports:
- id: game
container_port: 9876
protocol: udp
purpose: game
publish: true
required: true
- id: query
container_port: 9877
protocol: udp
purpose: query
publish: true
required: true
- id: rcon
container_port: 9878
protocol: tcp
purpose: integration
publish: false
required: true
storage:
mounts:
- id: persistent
container_path: /app/vrising
category: player_data
backup: true
user_configurable: true
read_only: false
- id: server
container_path: /steamcmd/vrising
category: runtime
backup: false
user_configurable: false
read_only: false
configuration:
fields:
- id: persistent_data_path
label: Persistent data path
type: string
target:
kind: environment
name: V_RISING_SERVER_PERSISTENT_DATA_PATH
apply: restart_required
visibility: admin
required: true
default: /app/vrising
- id: branch
label: Steam branch
type: string
target:
kind: environment
name: V_RISING_SERVER_BRANCH
apply: restart_required
visibility: admin
required: true
default: public
- id: start_mode
label: Server start mode
type: integer
target:
kind: environment
name: V_RISING_SERVER_START_MODE
apply: restart_required
visibility: admin
required: true
default: 0
minimum: 0
maximum: 2
- id: update_mode
label: Automatic server updates
type: boolean
target:
kind: environment
name: V_RISING_SERVER_UPDATE_MODE
apply: restart_required
visibility: admin
required: true
default: false
- id: default_host_settings
label: Manage host settings from DoGaMa
type: boolean
target:
kind: environment
name: V_RISING_SERVER_DEFAULT_HOST_SETTINGS
apply: restart_required
visibility: admin
required: true
default: true
- id: default_game_settings
label: Manage game settings from DoGaMa
type: boolean
target:
kind: environment
name: V_RISING_SERVER_DEFAULT_GAME_SETTINGS
apply: restart_required
visibility: admin
required: true
default: true
- id: server_name
label: Server name
type: string
target:
kind: environment
name: V_RISING_SERVER_NAME
apply: restart_required
visibility: members
required: true
default: DoGaMa V Rising Server
- id: server_description
label: Server description
type: string
target:
kind: environment
name: V_RISING_SERVER_DESCRIPTION
apply: restart_required
visibility: members
required: false
default: Managed by DoGaMa
- id: server_password
label: Player password
type: secret
target:
kind: environment
name: V_RISING_SERVER_PASSWORD
apply: restart_required
visibility: secret
required: false
- id: game_port
label: Game port
type: integer
target:
kind: environment
name: V_RISING_SERVER_GAME_PORT
apply: restart_required
visibility: admin
required: true
default: 9876
minimum: 1
maximum: 65535
- id: query_port
label: Query port
type: integer
target:
kind: environment
name: V_RISING_SERVER_QUERY_PORT
apply: restart_required
visibility: admin
required: true
default: 9877
minimum: 1
maximum: 65535
- id: rcon_enabled
label: Enable RCON
type: boolean
target:
kind: environment
name: V_RISING_SERVER_RCON_ENABLED
apply: restart_required
visibility: admin
required: true
default: true
- id: rcon_port
label: RCON port
type: integer
target:
kind: environment
name: V_RISING_SERVER_RCON_PORT
apply: restart_required
visibility: admin
required: true
default: 9878
minimum: 1
maximum: 65535
- id: rcon_password
label: RCON password
type: secret
target:
kind: environment
name: V_RISING_SERVER_RCON_PASSWORD
apply: restart_required
visibility: secret
required: true
- id: max_players
label: Maximum players
type: integer
target:
kind: environment
name: V_RISING_SERVER_MAX_CONNECTED_USERS
apply: restart_required
visibility: members
required: true
default: 40
minimum: 1
maximum: 60
- id: max_admins
label: Maximum administrators
type: integer
target:
kind: environment
name: V_RISING_SERVER_MAX_CONNECTED_ADMINS
apply: restart_required
visibility: admin
required: true
default: 4
minimum: 1
maximum: 20
- id: save_name
label: Save name
type: string
target:
kind: environment
name: V_RISING_SERVER_SAVE_NAME
apply: restart_required
visibility: admin
required: true
default: dogama
- id: list_on_steam
label: List server on Steam
type: boolean
target:
kind: environment
name: V_RISING_SERVER_LIST_ON_STEAM
apply: restart_required
visibility: members
required: true
default: true
- id: list_on_epic_eos
label: List server on Epic Online Services
type: boolean
target:
kind: environment
name: V_RISING_SERVER_LIST_ON_EPIC_EOS
apply: restart_required
visibility: members
required: true
default: false
- id: auto_save_count
label: Autosave count
type: integer
target:
kind: environment
name: V_RISING_SERVER_AUTO_SAVE_COUNT
apply: restart_required
visibility: admin
required: true
default: 50
minimum: 1
maximum: 100
- id: auto_save_interval
label: Autosave interval
type: integer
target:
kind: environment
name: V_RISING_SERVER_AUTO_SAVE_INTERVAL
apply: restart_required
visibility: admin
required: true
default: 300
minimum: 60
maximum: 3600
- id: game_settings_preset
label: Game settings preset
type: string
target:
kind: environment
name: V_RISING_SERVER_GAME_SETTINGS_PRESET
apply: restart_required
visibility: members
required: false
default: ""
- id: clan_size
label: Clan size
type: integer
target:
kind: environment
name: V_RISING_SERVER_CLAN_SIZE
apply: restart_required
visibility: members
required: true
default: 4
minimum: 1
maximum: 10
- id: pvp
label: Enable PvP
type: boolean
target:
kind: environment
name: V_RISING_SERVER_GAME_ENABLE_PVP
apply: restart_required
visibility: members
required: true
default: false
- id: disable_blood_drain
label: Disable castle blood drain
type: boolean
target:
kind: environment
name: V_RISING_SERVER_GAME_DISABLE_BLOOD_DRAIN
apply: restart_required
visibility: members
required: true
default: false
- id: disable_decay
label: Disable castle decay
type: boolean
target:
kind: environment
name: V_RISING_SERVER_GAME_DISABLE_DECAY
apply: restart_required
visibility: members
required: true
default: false
- id: job_worker_count
label: Unity job worker count
type: integer
target:
kind: environment
name: V_RISING_SERVER_JOB_WORKER_COUNT
apply: restart_required
visibility: admin
required: true
default: 8
minimum: 1
maximum: 32
capabilities:
- graceful_shutdown
backup:
strategy: stop_then_archive
source_mounts:
- persistent
restart_after_backup: true
healthcheck:
type: tcp
port_id: rcon
startup_timeout_seconds: 600
interval_seconds: 10
mods:
supported: false
imports:
supported: true
accepted_formats:
- zip
- tar
- tar.gz
- tar.zst
- directory
max_extracted_size_gb: 50
required_paths: []
destination_mount: persistent
destination_relative_path: Saves
requires_stopped_server: true
updates:
backup_before_update: true
automatic_default: false
rollback_on_failure: true
health_timeout_seconds: 600
compatibility:
minimum_manager_version: 1.0.0
requires_instance_migration: false
+63 -25
View File
@@ -12,23 +12,49 @@ import (
func TestInitializeOfficialAndScanDirPreservesLocalFiles(t *testing.T) {
root := t.TempDir()
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
t.Fatal(err)
}
first, err := catalog.ScanDir(root)
if err != nil || len(first.Valid) != 1 || first.Valid[0].Template.Game.Image == "" {
t.Fatalf("first scan = %#v, %v", first, err)
if err != nil {
t.Fatal(err)
}
if len(first.Valid[0].Template.Configuration.Fields) == 0 {
t.Fatal("configuration fields were lost")
if len(first.Valid) < 2 {
t.Fatalf("expected at least 2 valid official templates, got %#v", first)
}
var palworld *catalog.Snapshot
for i := range first.Valid {
if first.Valid[i].Template.ID == "palworld-official" {
palworld = &first.Valid[i]
break
}
}
if palworld == nil {
t.Fatal(`official template "palworld-official" is missing`)
}
if palworld.Template.Game.Artwork.Image == "" {
t.Fatal("palworld artwork image was lost")
}
if len(palworld.Template.Configuration.Fields) == 0 {
t.Fatal("palworld configuration fields were lost")
}
path := filepath.Join(root, "palworld", "template.yaml")
if err := os.WriteFile(path, []byte("local customization"), 0o644); err != nil {
t.Fatal(err)
}
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(path)
if err != nil || string(body) != "local customization" {
t.Fatalf("local template was overwritten: %q, %v", body, err)
@@ -37,42 +63,54 @@ func TestInitializeOfficialAndScanDirPreservesLocalFiles(t *testing.T) {
func TestScanDirKeepsValidTemplatesWhenOneIsInvalid(t *testing.T) {
root := t.TempDir()
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
t.Fatal(err)
}
body, err := catalogdata.Files.ReadFile("palworld/template.yaml")
if err != nil {
t.Fatal(err)
}
broken := strings.Replace(string(body), "image: https://www.codesproduit.fr/wp-content/uploads/2024/03/PALWORLD-PC-COVER.jpg", "image: not-a-url", 1)
broken := strings.Replace(
string(body),
"image: assets/banner.jpg",
"image: ../invalid.jpg",
1,
)
if err := os.Mkdir(filepath.Join(root, "broken"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "broken", "template.yaml"), []byte(broken), 0o644); err != nil {
t.Fatal(err)
}
result, err := catalog.ScanDir(root)
if err != nil || result.Found != 2 || len(result.Valid) != 1 || len(result.Errors) != 1 {
t.Fatalf("scan = %#v, %v", result, err)
}
if result.Errors[0].Template != "broken" || !strings.Contains(result.Errors[0].Message, "/game/image") {
t.Fatalf("error = %#v", result.Errors)
}
}
func TestScanDirRejectsTemplateSymlinks(t *testing.T) {
root := t.TempDir()
if err := catalog.InitializeOfficial(root, catalogdata.Files); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(root, "palworld", "assets", "icon.png"), filepath.Join(root, "palworld", "assets", "linked-icon.png")); err != nil {
if err := os.WriteFile(
filepath.Join(root, "broken", "template.yaml"),
[]byte(broken),
0o644,
); err != nil {
t.Fatal(err)
}
result, err := catalog.ScanDir(root)
if err != nil || len(result.Valid) != 0 || len(result.Errors) != 1 {
t.Fatalf("scan = %#v, %v", result, err)
if err != nil {
t.Fatal(err)
}
if result.Errors[0].Message != "template contains unsupported symbolic links" {
if result.Found != 3 {
t.Fatalf("found = %d, want 3; scan = %#v", result.Found, result)
}
if len(result.Valid) != 2 {
t.Fatalf("valid = %d, want 2; scan = %#v", len(result.Valid), result)
}
if len(result.Errors) != 1 {
t.Fatalf("errors = %d, want 1; scan = %#v", len(result.Errors), result)
}
if result.Errors[0].Template != "broken" ||
!strings.Contains(result.Errors[0].Message, "/game/artwork/image") {
t.Fatalf("error = %#v", result.Errors)
}
}
+17 -8
View File
@@ -49,13 +49,11 @@ type Template struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Image string `json:"image"`
Artwork struct {
Logo string `json:"logo"`
Image string `json:"image"`
Poster string `json:"poster,omitempty"`
LogoSourceURL string `json:"logo_source_url,omitempty"`
Attribution string `json:"attribution"`
Logo string `json:"logo"`
Image string `json:"image"`
Poster string `json:"poster"`
Attribution string `json:"attribution"`
} `json:"artwork"`
} `json:"game"`
Requirements struct {
@@ -402,9 +400,20 @@ func validationErrors(err error, document *yaml.Node) error {
func crossValidate(template Template, assetRoot string, source fs.FS) []ValidationIssue {
var issues []ValidationIssue
for field, asset := range map[string]string{"logo": template.Game.Artwork.Logo, "image": template.Game.Artwork.Image} {
for field, asset := range map[string]string{
"logo": template.Game.Artwork.Logo,
"image": template.Game.Artwork.Image,
"poster": template.Game.Artwork.Poster,
} {
if asset == "" {
continue
}
if _, err := fs.Stat(source, path.Join(assetRoot, asset)); err != nil {
issues = append(issues, ValidationIssue{Path: "/game/artwork/" + field, Message: "artwork asset is missing"})
issues = append(issues, ValidationIssue{
Path: "/game/artwork/" + field,
Message: "artwork asset is missing",
})
}
}
ports := make(map[string]Port)
+76 -5
View File
@@ -18,11 +18,67 @@ func TestBuiltInCatalogValidatesDeterministically(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(first) != 1 || first[0].Template.ID != "palworld-official" || first[0].Digest != second[0].Digest || first[0].CanonicalYAML != second[0].CanonicalYAML {
t.Fatalf("catalog snapshots = %#v, %#v", first, second)
if len(first) != len(second) {
t.Fatalf("catalog snapshot count differs: first=%d second=%d", len(first), len(second))
}
if first[0].Template.Game.Artwork.Logo != "assets/icon.png" || first[0].Template.Game.Artwork.Image != "assets/banner.jpg" {
t.Fatalf("catalog artwork = %#v", first[0].Template.Game.Artwork)
if len(first) < 2 {
t.Fatalf("expected at least 2 built-in templates, got %d", len(first))
}
secondByID := make(map[string]catalog.Snapshot, len(second))
for _, snapshot := range second {
secondByID[snapshot.Template.ID] = snapshot
}
firstByID := make(map[string]catalog.Snapshot, len(first))
for _, snapshot := range first {
firstByID[snapshot.Template.ID] = snapshot
other, ok := secondByID[snapshot.Template.ID]
if !ok {
t.Fatalf("template %q missing from second catalog load", snapshot.Template.ID)
}
if snapshot.Digest != other.Digest {
t.Fatalf(
"template %q digest is not deterministic: first=%q second=%q",
snapshot.Template.ID,
snapshot.Digest,
other.Digest,
)
}
if snapshot.CanonicalYAML != other.CanonicalYAML {
t.Fatalf("template %q canonical YAML is not deterministic", snapshot.Template.ID)
}
}
palworld, ok := firstByID["palworld-official"]
if !ok {
t.Fatal(`built-in template "palworld-official" is missing`)
}
if palworld.Template.Game.Artwork.Logo != "assets/icon.png" ||
palworld.Template.Game.Artwork.Image != "assets/banner.jpg" ||
palworld.Template.Game.Artwork.Poster != "assets/poster.jpg" {
t.Fatalf("palworld artwork = %#v", palworld.Template.Game.Artwork)
}
vrising, ok := firstByID["vrising-didstopia"]
if !ok {
t.Fatal(`built-in template "vrising-didstopia" is missing`)
}
if vrising.Template.Game.ID != "vrising" {
t.Fatalf("vrising game id = %q", vrising.Template.Game.ID)
}
if vrising.Template.Game.Artwork.Logo != "assets/icon.png" ||
vrising.Template.Game.Artwork.Image != "assets/banner.jpg" ||
vrising.Template.Game.Artwork.Poster != "assets/poster.jpg" {
t.Fatalf("vrising artwork = %#v", vrising.Template.Game.Artwork)
}
}
@@ -33,15 +89,18 @@ func TestCrossValidationRejectsMissingArtworkAsset(t *testing.T) {
}
body = []byte(strings.Replace(string(body), "image: assets/banner.jpg", "image: assets/missing.jpg", 1))
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
var validation *catalog.ValidationErrors
if !errors.As(err, &validation) {
t.Fatalf("validation error = %#v", err)
}
for _, issue := range validation.Issues {
if issue.Path == "/game/artwork/image" {
return
}
}
t.Fatalf("issues = %#v", validation.Issues)
}
@@ -52,10 +111,12 @@ func TestSchemaErrorsContainFieldPathAndLine(t *testing.T) {
}
body = []byte(strings.Replace(string(body), "schema_version: 1", "schema_version: 2", 1))
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
var validation *catalog.ValidationErrors
if !errors.As(err, &validation) || len(validation.Issues) == 0 {
t.Fatalf("validation error = %#v", err)
}
if validation.Issues[0].Path == "" || validation.Issues[0].Line == 0 {
t.Fatalf("validation issue = %#v", validation.Issues[0])
}
@@ -66,17 +127,27 @@ func TestCrossValidationRejectsUnknownBackupMount(t *testing.T) {
if err != nil {
t.Fatal(err)
}
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
body = []byte(strings.Replace(normalized, " - saved\n restart_after_backup", " - missing\n restart_after_backup", 1))
body = []byte(strings.Replace(
normalized,
" - saved\n restart_after_backup",
" - missing\n restart_after_backup",
1,
))
_, err = catalog.Validate(body, "palworld", catalogdata.Files)
var validation *catalog.ValidationErrors
if !errors.As(err, &validation) {
t.Fatalf("validation error = %#v", err)
}
found := false
for _, issue := range validation.Issues {
found = found || issue.Path == "/backup/source_mounts"
}
if !found {
t.Fatalf("issues = %#v", validation.Issues)
}
+3 -5
View File
@@ -21,22 +21,20 @@
"game": {
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "description", "image", "artwork"],
"required": ["id", "name", "description", "artwork"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
"description": { "type": "string", "minLength": 1, "maxLength": 2000 },
"image": { "type": "string", "format": "uri", "pattern": "^https://", "maxLength": 2000 },
"website": { "type": "string", "format": "uri", "pattern": "^https://" },
"artwork": {
"type": "object",
"additionalProperties": false,
"required": ["logo", "image", "attribution"],
"required": ["logo", "image", "poster", "attribution"],
"properties": {
"logo": { "$ref": "#/$defs/relativeAssetPath" },
"image": { "$ref": "#/$defs/relativeAssetPath" },
"poster_source_url": { "type": "string", "format": "uri", "pattern": "^https://" },
"logo_source_url": { "type": "string", "format": "uri", "pattern": "^https://" },
"poster": { "$ref": "#/$defs/relativeAssetPath" },
"attribution": { "type": "string", "maxLength": 500 }
}
}