The builder must be game-agnostic, but its prompts named games: AUTHORING.md (the model's rules) used three games as examples, the research prompt listed two games' editions, the schema described containerUser by one game's image, and every draft prompt included two real templates from the repo, so the model saw, and could copy, other games' commands and options. - AUTHORING.md, the research prompt and the schema describe the rules without naming a game. - The draft prompt's format example is now one invented server (no real command, options, ports or app id), checked by TestExampleTemplateFollowsTheRules against validation and the draft checks. - Comments, help texts, BUILDER.md and test fixtures use invented names and values. - TestBuilderIsGameAgnostic reads every game's name and id from games/ and fails if any appears in the builder's code, tests, rules, schemas or docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
6.6 KiB
Go
152 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func textSettings(keys ...string) map[string]any {
|
|
props := map[string]any{}
|
|
for _, k := range keys {
|
|
props[k] = map[string]any{"type": "string"}
|
|
}
|
|
return map[string]any{"properties": props}
|
|
}
|
|
|
|
func TestSplitSettingsCatchesAnArgsString(t *testing.T) {
|
|
// A real draft: quoted in the assignment, split by the unquoted $ARGS
|
|
game := map[string]any{
|
|
"defaultStartupCommand": `ARGS="-name ${SERVER_NAME:-Example Server} -port 30000 -world ${WORLD_NAME:-Dedicated}"; ` +
|
|
`[ -n "$PASSWORD" ] && ARGS="$ARGS -password $PASSWORD"; [ "$CROSSPLAY" = "true" ] && ARGS="$ARGS -crossplay"; ` +
|
|
`HOME=/server ./example_server $ARGS`,
|
|
"configSchema": textSettings("ServerName", "WorldName", "Password"),
|
|
}
|
|
problems := splitSettings(game)
|
|
got := strings.Join(problems, "\n")
|
|
if len(problems) != 3 {
|
|
t.Fatalf("want ServerName, WorldName and Password, got:\n%s", got)
|
|
}
|
|
for _, key := range []string{"setting Password", "setting ServerName", "setting WorldName", "inside $ARGS"} {
|
|
if !strings.Contains(got, key) {
|
|
t.Errorf("missing %q in:\n%s", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitSettingsAcceptsQuotedAndArrays(t *testing.T) {
|
|
for _, startup := range []string{
|
|
`./example_server -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 30000`,
|
|
`args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./example_server "${args[@]}"`,
|
|
`bash -c 'exec ./server -name "$SERVER_NAME"'`,
|
|
} {
|
|
game := map[string]any{"defaultStartupCommand": startup, "configSchema": textSettings("ServerName", "WorldName", "Password")}
|
|
if p := splitSettings(game); len(p) != 0 {
|
|
t.Errorf("%s\n flagged: %v", startup, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitSettingsDirectAndArrayMisuse(t *testing.T) {
|
|
cases := map[string]string{
|
|
`./server -name ${SERVER_NAME}`: "is not in double quotes",
|
|
`bash -c 'exec ./server -name $SERVER_NAME'`: "is not in double quotes",
|
|
`args=(-name "$SERVER_NAME"); ./server ${args[@]}`: "inside $args",
|
|
`args=(-name $SERVER_NAME); ./server "${args[@]}"`: "is not in double quotes",
|
|
`export OPTS="-name ${SERVER_NAME}" && ./run $OPTS`: "inside $OPTS",
|
|
}
|
|
for startup, want := range cases {
|
|
game := map[string]any{"defaultStartupCommand": startup, "configSchema": textSettings("ServerName")}
|
|
got := strings.Join(splitSettings(game), "\n")
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("%s\n want %q, got %q", startup, want, got)
|
|
}
|
|
}
|
|
// Numbers and booleans can't contain spaces
|
|
game := map[string]any{"defaultStartupCommand": `./server -port ${GAME_PORT}`, "configSchema": map[string]any{
|
|
"properties": map[string]any{"GamePort": map[string]any{"type": "integer"}}}}
|
|
if p := splitSettings(game); len(p) != 0 {
|
|
t.Errorf("integer setting flagged: %v", p)
|
|
}
|
|
}
|
|
|
|
func TestPortSettings(t *testing.T) {
|
|
game := map[string]any{
|
|
"defaultPorts": []any{"30000/udp", "30001/udp"},
|
|
"defaultConfig": map[string]any{
|
|
"GamePort": json.Number("30000"),
|
|
"QueryPort": json.Number("27015"),
|
|
"RCONPort": json.Number("25575"),
|
|
"PublicPort": "{PUBLIC_PORT_30000}",
|
|
"Transport": "udp",
|
|
"Export": json.Number("9"),
|
|
},
|
|
"configSchema": map[string]any{"properties": map[string]any{
|
|
"GamePort": map[string]any{"type": "integer"}, "QueryPort": map[string]any{"type": "integer"},
|
|
}},
|
|
}
|
|
got := strings.Join(portSettings(game), "\n")
|
|
if !strings.Contains(got, "setting GamePort lets owners change the port") || !strings.Contains(got, "write 30000 directly") {
|
|
t.Errorf("editable published port not caught:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "setting QueryPort defaults to port 27015, which defaultPorts does not publish") {
|
|
t.Errorf("unpublished port not caught:\n%s", got)
|
|
}
|
|
for _, key := range []string{"RCONPort", "PublicPort", "Transport", "Export"} {
|
|
if strings.Contains(got, "setting "+key+" ") {
|
|
t.Errorf("%s should be left alone:\n%s", key, got)
|
|
}
|
|
}
|
|
|
|
// A published port written by the template itself, not offered to owners, is fine
|
|
delete(game["configSchema"].(map[string]any)["properties"].(map[string]any), "GamePort")
|
|
delete(game["defaultConfig"].(map[string]any), "QueryPort")
|
|
if p := portSettings(game); len(p) != 0 {
|
|
t.Errorf("got %v", p)
|
|
}
|
|
}
|
|
|
|
func TestUndocumentedOptionsCatchesMaxPlayers(t *testing.T) {
|
|
// A real draft's startup: every option is documented except -maxplayers
|
|
game := map[string]any{"defaultStartupCommand": `args=(-name "${SERVER_NAME:-GBRHosting - Example}" -port 30000 -world "${WORLD_NAME:-Dedicated}" -datadir /server); ` +
|
|
`[ -n "${SERVER_PASSWORD}" ] && args+=(-password "${SERVER_PASSWORD}"); [ "${CROSSPLAY}" = "true" ] && args+=(-crossplay); ` +
|
|
`[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); ./example_server "${args[@]}"`}
|
|
docs := "Launch with `-name`, `-port`, `-world`, `-password`, `-datadir <path>` and `-crossplay` to allow consoles."
|
|
got := undocumentedOptions(game, docs, nil)
|
|
if len(got) != 1 || !strings.Contains(got[0], "passes -maxplayers,") {
|
|
t.Fatalf("want only -maxplayers flagged, got %v", got)
|
|
}
|
|
// Once removed, nothing is left to flag
|
|
game["defaultStartupCommand"] = strings.Replace(game["defaultStartupCommand"].(string), `[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); `, "", 1)
|
|
if got := undocumentedOptions(game, docs, nil); len(got) != 0 {
|
|
t.Errorf("got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestUndocumentedOptionsIgnoresShellAndUsesImageScripts(t *testing.T) {
|
|
game := map[string]any{"defaultStartupCommand": `mkdir -p /server/.config; cp -f /a /b; [ -n "$X" ] && [ "$Y" -eq 1 ] && ` +
|
|
`exec ./server --listen-port=7777 -ServerPlatform=ALL -logs`}
|
|
facts := &imageFacts{Scripts: map[string]string{"/start.sh": `exec "$@" -logs`}}
|
|
got := strings.Join(undocumentedOptions(game, "Use --listen-port and -ServerPlatform=ALL for crossplay.", facts), "\n")
|
|
if got != "" {
|
|
t.Errorf("shell options, documented ones and ones the image's script uses must pass, got %q", got)
|
|
}
|
|
// Without sources the check is off (draft run by hand, or no reader configured)
|
|
game["defaultStartupCommand"] = "./server -madeup"
|
|
if p := undocumentedOptions(game, "", nil); p != nil {
|
|
t.Errorf("got %v", p)
|
|
}
|
|
if p := undocumentedOptions(game, "unrelated page", nil); len(p) != 1 {
|
|
t.Errorf("want -madeup flagged, got %v", p)
|
|
}
|
|
}
|
|
|
|
func TestUndocumentedOptionsWholeWords(t *testing.T) {
|
|
// "-port" in the docs doesn't document "-portal", and "-port" isn't documented by "-portal"
|
|
game := map[string]any{"defaultStartupCommand": "./server -port 1 -portal x"}
|
|
got := strings.Join(undocumentedOptions(game, "options: -port", nil), "")
|
|
if !strings.Contains(got, "passes -portal,") || strings.Contains(got, "-port,") {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|