blob: e923a16a4a3346ecdd6d4b0c3555a962cc00a9d5 [file] [log] [blame] [raw]
package net.glowstone.util.config;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static net.glowstone.util.config.ServerConfig.Validators.typeCheck;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.function.Predicate;
import java.util.logging.Level;
import lombok.Getter;
import net.glowstone.GlowServer;
import net.glowstone.util.CompatibilityBundle;
import net.glowstone.util.DynamicallyTypedMap;
import org.bukkit.Difficulty;
import org.bukkit.GameMode;
import org.bukkit.WorldType;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.util.FileUtil;
import org.jetbrains.annotations.NonNls;
import org.yaml.snakeyaml.error.YAMLException;
/**
* Utilities for handling the server configuration files.
*/
public class ServerConfig implements DynamicallyTypedMap<ServerConfig.Key> {
public static final int DEFAULT_PORT = 25565;
/**
* The directory configurations are stored in.
*/
@Getter
private final File directory;
/**
* The main configuration file.
*/
private final File configFile;
/**
* The actual configuration data.
*/
private final YamlConfiguration config = new YamlConfiguration();
/**
* Extra configuration files (help, permissions, commands).
*/
private final Map<String, YamlConfiguration> extraConfig = new HashMap<>();
/**
* Parameters with which the server is ran.
*/
private final Map<Key, Object> parameters;
/**
* Initialize a new ServerConfig and associated settings.
*
* @param directory The config directory, or null for default.
* @param configFile The config file, or null for default.
* @param parameters The command-line parameters used as overrides.
*/
public ServerConfig(File directory, File configFile, Map<Key, Object> parameters) {
checkNotNull(directory);
checkNotNull(configFile);
checkNotNull(parameters);
this.directory = directory;
this.configFile = configFile;
this.parameters = parameters;
config.options().indent(4).copyHeader(true).header(
"glowstone.yml is the main configuration file for a Glowstone server\n"
+ "It contains everything from server.properties and bukkit.yml in a\n"
+ "normal CraftBukkit installation.\n\n"
+ "Configuration entries are documented on the wiki: "
+ "https://docs.glowstone.net/en/latest/Configuration_Guide/index.html\n"
+ "For help, join us on Discord: https://discord.gg/TFJqhsC");
}
////////////////////////////////////////////////////////////////////////////
// Modification
/**
* Save the configuration back to file.
*/
public void save() {
try {
config.save(configFile);
} catch (IOException e) {
GlowServer.logger.log(Level.SEVERE, "Failed to write config: " + configFile, e);
}
}
/**
* Change a configuration value at runtime.
*
* @param key the config key to write the value to
* @param value value to write to config key
* @see ServerConfig#save()
*/
public void set(Key key, Object value) {
parameters.replace(key, value);
config.set(key.path, value);
}
////////////////////////////////////////////////////////////////////////////
// Value getters
@Override
public String getString(Key key) {
if (parameters.containsKey(key)) {
return parameters.get(key).toString();
}
String string = config.getString(key.path, key.def.toString());
parameters.put(key, string);
return string;
}
@Override
public int getInt(Key key) {
if (parameters.containsKey(key)) {
return (Integer) parameters.get(key);
}
int integer = config.getInt(key.path, (Integer) key.def);
parameters.put(key, integer);
return integer;
}
@Override
public boolean getBoolean(Key key) {
if (parameters.containsKey(key)) {
return (Boolean) parameters.get(key);
}
boolean bool = config.getBoolean(key.path, (Boolean) key.def);
parameters.put(key, bool);
return bool;
}
/**
* Retrieves a section as a list of maps.
*
* @param key the key to look up
* @return the value as a list of maps
*/
@SuppressWarnings("unchecked")
public List<Map<?, ?>> getMapList(Key key) {
if (parameters.containsKey(key)) {
return (List<Map<?, ?>>) parameters.get(key);
}
// there's no get or default method for the getMapList method, so using contains.
if (!config.contains(key.path)) {
parameters.put(key, key.def);
return (List<Map<?, ?>>) key.def;
}
return config.getMapList(key.path);
}
////////////////////////////////////////////////////////////////////////////
// Fancy stuff
/**
* Returns the file that contains a given setting. If it doesn't exist, it is created and
* populated with defaults.
*
* @param key the configuration setting
* @return the file containing that setting
*/
public ConfigurationSection getConfigFile(Key key) {
String filename = getString(key);
if (extraConfig.containsKey(filename)) {
return extraConfig.get(filename);
}
YamlConfiguration conf = new YamlConfiguration();
File file = getFile(filename);
File migrateFrom = new File(key.def.toString());
// create file if it doesn't exist
if (!file.exists()) {
if (migrateFrom.exists()) {
FileUtil.copy(migrateFrom, file);
} else {
copyDefaults(key.def.toString(), file);
}
}
// read in config
try {
conf.load(file);
} catch (IOException e) {
GlowServer.logger.log(Level.SEVERE, "Failed to read config: " + file, e);
} catch (InvalidConfigurationException e) {
report(file, e);
}
extraConfig.put(filename, conf);
return conf;
}
public ConfigurationSection getWorlds() {
return config.getConfigurationSection("worlds");
}
public File getFile(@NonNls String filename) {
return new File(directory, filename);
}
////////////////////////////////////////////////////////////////////////////
// Load and internals
/**
* Loads the server config from disk. If it doesn't exist, the default config is written,
* creating the folder if necessary. If it's in the old bukkit.yml format and/or incomplete, it
* is converted to canonical form and saved.
*/
public void load() {
// load extra config files again next time they're needed
extraConfig.clear();
boolean changed = false;
// create default file if needed
if (!configFile.exists()) {
GlowServer.logger.info("Creating default config: " + configFile);
// create config directory
if (!directory.isDirectory() && !directory.mkdirs()) {
GlowServer.logger.severe("Cannot create directory: " + directory);
return;
}
// load default config
for (Key key : Key.values()) {
config.set(key.path, key.def);
}
// attempt to migrate
if (migrate()) {
GlowServer.logger.info("Migrated configuration from previous installation");
}
changed = true;
} else {
// load config
try {
config.load(configFile);
} catch (IOException e) {
GlowServer.logger.log(Level.SEVERE, "Failed to read config: " + configFile, e);
} catch (InvalidConfigurationException e) {
report(configFile, e);
}
// add missing keys to the current config
for (Key key : Key.values()) {
if (!config.contains(key.path)) {
config.set(key.path, key.def);
changed = true;
} else if (key.validator != null) {
// validate existing values
Object val = config.get(key.path);
if (!key.validator.test(val)) {
GlowServer.logger.warning(
"Invalid config value for '" + key.path + "' (" + val + "), "
+ "resetting to default (" + key.def + ")");
config.set(key.path, key.def);
changed = true;
}
}
}
}
if (changed) {
save();
}
}
private void copyDefaults(String source, File dest) {
URL resource = getClass().getClassLoader().getResource("defaults/" + source);
if (resource == null) {
GlowServer.logger.warning("Could not find default " + source + " on classpath");
return;
}
try (final InputStream in = resource.openStream();
final OutputStream out = new FileOutputStream(dest)) {
byte[] buf = new byte[2048];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
}
} catch (IOException e) {
GlowServer.logger.log(Level.WARNING, "Could not save default config: " + dest, e);
return;
}
GlowServer.logger.info("Created default config: " + dest);
}
private void report(File file, InvalidConfigurationException e) {
if (e.getCause() instanceof YAMLException) {
GlowServer.logger.severe("Config file " + file + " isn't valid! " + e.getCause());
} else if (e.getCause() == null || e.getCause() instanceof ClassCastException) {
GlowServer.logger.severe("Config file " + file + " isn't valid!");
} else {
GlowServer.logger
.log(Level.SEVERE, "Cannot load " + file + ": " + e.getCause().getClass(), e);
}
}
public YamlConfiguration getConfig() {
return config;
}
private boolean migrate() {
boolean migrateStatus = false;
File bukkitYml = new File("bukkit.yml");
if (bukkitYml.exists()) {
YamlConfiguration bukkit = new YamlConfiguration();
try {
bukkit.load(bukkitYml);
} catch (InvalidConfigurationException e) {
report(bukkitYml, e);
} catch (IOException e) {
GlowServer.logger.log(Level.WARNING, "Could not migrate from " + bukkitYml, e);
}
for (Key key : Key.values()) {
if (key.migrate == Migrate.BUKKIT && bukkit.contains(key.migratePath)) {
config.set(key.path, bukkit.get(key.migratePath));
migrateStatus = true;
}
}
config.set("aliases", bukkit.get("aliases"));
config.set("worlds", bukkit.get("worlds"));
}
File serverProps = new File("server.properties");
if (serverProps.exists()) {
Properties props = new Properties();
try {
props.load(new FileInputStream(serverProps));
} catch (IOException e) {
GlowServer.logger.log(Level.WARNING, "Could not migrate from " + serverProps, e);
}
for (Key key : Key.values()) {
if (key.migrate == Migrate.PROPS && props.containsKey(key.migratePath)) {
String value = props.getProperty(key.migratePath);
if (key.def instanceof Integer) {
try {
config.set(key.path, Integer.parseInt(value));
} catch (NumberFormatException e) {
GlowServer.logger.log(Level.WARNING,
"Could not migrate " + key.migratePath + " from "
+ serverProps, e);
continue;
}
} else if (key.def instanceof Boolean) {
config.set(key.path, Boolean.parseBoolean(value));
} else {
config.set(key.path, value);
}
migrateStatus = true;
}
}
}
return migrateStatus;
}
/**
* An enum containing configuration keys used by the server.
*
* <p>NOTE: Do not use Collections.emptyList() as a default value because Jackson will alias it
* with any other instances of emptyLIst. Use a new instance of an empty ArrayList instead.
*/
public enum Key {
// server
SERVER_IP("server.ip", "", Migrate.PROPS, "server-ip", String.class::isInstance),
SERVER_PORT("server.port", DEFAULT_PORT, Migrate.PROPS, "server-port", Validators.PORT),
SERVER_NAME("server.name", "Glowstone Server", Migrate.PROPS, "server-name",
String.class::isInstance),
LOG_FILE("server.log-file", "logs/log-%D.txt", String.class::isInstance),
ONLINE_MODE("server.online-mode", true, Migrate.PROPS, "online-mode",
Boolean.class::isInstance),
MAX_PLAYERS("server.max-players", 20, Migrate.PROPS, "max-players",
Validators.POSITIVE_INTEGER),
WHITELIST("server.whitelisted", false, Migrate.PROPS, "white-list",
Boolean.class::isInstance),
MOTD("server.motd", "A Glowstone server", Migrate.PROPS, "motd",
String.class::isInstance),
SHUTDOWN_MESSAGE("server.shutdown-message", "Server shutting down.", Migrate.BUKKIT,
"settings.shutdown-message", String.class::isInstance),
ALLOW_CLIENT_MODS("server.allow-client-mods", true, Boolean.class::isInstance),
DNS_OVERRIDES("server.dns", new ArrayList<>()),
// console
USE_JLINE("console.use-jline", true, Boolean.class::isInstance),
CONSOLE_PROMPT("console.prompt", "> ", String.class::isInstance),
CONSOLE_DATE("console.date-format", "HH:mm:ss", String.class::isInstance),
CONSOLE_LOG_DATE("console.log-date-format", "yyyy/MM/dd HH:mm:ss",
String.class::isInstance),
// game props
GAMEMODE("game.gamemode", "SURVIVAL", Migrate.PROPS, "gamemode",
Validators.forEnum(GameMode.class)),
FORCE_GAMEMODE("game.gamemode-force", false, Migrate.PROPS, "force-gamemode",
Boolean.class::isInstance),
DIFFICULTY("game.difficulty", "NORMAL", Migrate.PROPS, "difficulty",
Validators.forEnum(Difficulty.class)),
HARDCORE("game.hardcore", false, Migrate.PROPS, "hardcore",
Boolean.class::isInstance),
PVP_ENABLED("game.pvp", true, Migrate.PROPS, "pvp",
Boolean.class::isInstance),
MAX_BUILD_HEIGHT("game.max-build-height", 256, Migrate.PROPS, "max-build-height",
Validators.POSITIVE_INTEGER),
ANNOUNCE_ACHIEVEMENTS("game.announce-achievements", true, Migrate.PROPS,
"announce-player-achievements", Boolean.class::isInstance),
// server.properties keys
ALLOW_FLIGHT("game.allow-flight", false, Migrate.PROPS, "allow-flight",
Boolean.class::isInstance),
ENABLE_COMMAND_BLOCK("game.command-blocks", false, Migrate.PROPS, "enable-command-block",
Boolean.class::isInstance),
//OP_PERMISSION_LEVEL(null, Migrate.PROPS, "op-permission-level"),
RESOURCE_PACK("game.resource-pack", "", Migrate.PROPS, "resource-pack",
String.class::isInstance),
RESOURCE_PACK_HASH("game.resource-pack-hash", "", Migrate.PROPS, "resource-pack-hash",
String.class::isInstance),
SNOOPER_ENABLED("server.snooper-enabled", false, Migrate.PROPS, "snooper-enabled",
Boolean.class::isInstance),
PREVENT_PROXY("server.prevent-proxy-connections", true, Migrate.PROPS,
"prevent-proxy-connections", Boolean.class::isInstance),
// creatures
SPAWN_MONSTERS("creatures.enable.monsters", true, Migrate.PROPS, "spawn-monsters",
Boolean.class::isInstance),
SPAWN_ANIMALS("creatures.enable.animals", true, Migrate.PROPS, "spawn-animals",
Boolean.class::isInstance),
SPAWN_NPCS("creatures.enable.npcs", true, Migrate.PROPS, "spawn-npcs",
Boolean.class::isInstance),
MONSTER_LIMIT("creatures.limit.monsters", 70, Migrate.BUKKIT, "spawn-limits.monsters",
Validators.NON_NEGATIVE_INTEGER),
ANIMAL_LIMIT("creatures.limit.animals", 15, Migrate.BUKKIT, "spawn-limits.animals",
Validators.NON_NEGATIVE_INTEGER),
WATER_ANIMAL_LIMIT("creatures.limit.water", 5, Migrate.BUKKIT,
"spawn-limits.water-animals",
Validators.NON_NEGATIVE_INTEGER),
AMBIENT_LIMIT("creatures.limit.ambient", 15, Migrate.BUKKIT, "spawn-limits.ambient",
Validators.NON_NEGATIVE_INTEGER),
MONSTER_TICKS("creatures.ticks.monsters", 1, Migrate.BUKKIT, "ticks-per.monster-spawns",
Validators.NON_NEGATIVE_INTEGER),
ANIMAL_TICKS("creatures.ticks.animal", 400, Migrate.BUKKIT, "ticks-per.animal-spawns",
Validators.NON_NEGATIVE_INTEGER),
// folders
PLUGIN_FOLDER("folders.plugins", "plugins", Validators.PATH),
UPDATE_FOLDER("folders.update", "update", Migrate.BUKKIT, "settings.update-folder",
Validators.PATH),
WORLD_FOLDER("folders.worlds", "worlds", Migrate.BUKKIT, "settings.world-container",
Validators.PATH),
LIBRARIES_FOLDER("folders.libraries", "lib", Validators.PATH),
// files
PERMISSIONS_FILE("files.permissions", "permissions.yml", Migrate.BUKKIT,
"settings.permissions-file", Validators.PATH),
COMMANDS_FILE("files.commands", "commands.yml", Validators.PATH),
HELP_FILE("files.help", "help.yml", Validators.PATH),
// advanced
CONNECTION_THROTTLE("advanced.connection-throttle", 4000, Migrate.BUKKIT,
"settings.connection-throttle",
Validators.NON_NEGATIVE_INTEGER),
//PING_PACKET_LIMIT(
// "advanced.ping-packet-limit", 100, Migrate.BUKKIT, "settings.ping-packet-limit"),
PLAYER_IDLE_TIMEOUT("advanced.idle-timeout", 0, Migrate.PROPS, "player-idle-timeout",
Validators.NON_NEGATIVE_INTEGER),
WARN_ON_OVERLOAD("advanced.warn-on-overload", true, Migrate.BUKKIT,
"settings.warn-on-overload", Boolean.class::isInstance),
EXACT_LOGIN_LOCATION("advanced.exact-login-location", false, Migrate.BUKKIT,
"settings.use-exact-login-location", Boolean.class::isInstance),
PLUGIN_PROFILING("advanced.plugin-profiling", false, Migrate.BUKKIT,
"settings.plugin-profiling", Boolean.class::isInstance),
WARNING_STATE("advanced.deprecated-verbose", "false", Migrate.BUKKIT,
"settings.deprecated-verbose"),
COMPRESSION_THRESHOLD("advanced.compression-threshold", 256, Migrate.PROPS,
"network-compression-threshold",
typeCheck(Integer.class).and(value -> value >= -1)),
PROXY_SUPPORT("advanced.proxy-support", false, Boolean.class::isInstance),
PLAYER_SAMPLE_COUNT("advanced.player-sample-count", 12,
Validators.NON_NEGATIVE_INTEGER),
GRAPHICS_COMPUTE("advanced.graphics-compute.enable", false),
GRAPHICS_COMPUTE_ANY_DEVICE("advanced.graphics-compute.use-any-device", false,
Boolean.class::isInstance),
REGION_CACHE_SIZE("advanced.region-file.cache-size", 256,
Validators.NON_NEGATIVE_INTEGER),
REGION_COMPRESSION("advanced.region-file.compression", true,
Boolean.class::isInstance),
PROFILE_LOOKUP_TIMEOUT("advanced.profile-lookup-timeout", 5,
Validators.NON_NEGATIVE_INTEGER),
SUGGEST_PLAYER_NAMES_WHEN_NULL_TAB_COMPLETIONS(
"advanced.suggest-player-name-when-null-tab-completions", true,
Boolean.class::isInstance),
// query rcon etc
QUERY_ENABLED("extras.query-enabled", false, Migrate.PROPS, "enable-query",
Boolean.class::isInstance),
QUERY_PORT("extras.query-port", 25614, Migrate.PROPS, "query.port", Validators.PORT),
QUERY_PLUGINS("extras.query-plugins", true, Migrate.BUKKIT, "settings.query-plugins",
Boolean.class::isInstance),
RCON_ENABLED("extras.rcon-enabled", false, Migrate.PROPS, "enable-rcon",
Boolean.class::isInstance),
RCON_PASSWORD("extras.rcon-password", "glowstone", Migrate.PROPS, "rcon.password",
String.class::isInstance),
RCON_PORT("extras.rcon-port", 25575, Migrate.PROPS, "rcon.port", Validators.PORT),
RCON_COLORS("extras.rcon-colors", true,
Boolean.class::isInstance),
// level props
LEVEL_NAME("world.name", "world", Migrate.PROPS, "level-name",
String.class::isInstance),
LEVEL_SEED("world.seed", "", Migrate.PROPS, "level-seed"),
LEVEL_TYPE("world.level-type", "DEFAULT", Migrate.PROPS, "level-type", Validators
.WORLD_TYPE),
SPAWN_RADIUS("world.spawn-radius", 16, Migrate.PROPS, "spawn-protection",
Validators.NON_NEGATIVE_INTEGER),
VIEW_DISTANCE("world.view-distance", 8, Migrate.PROPS, "view-distance",
Validators.POSITIVE_INTEGER),
GENERATE_STRUCTURES("world.gen-structures", true, Migrate.PROPS, "generate-structures",
Boolean.class::isInstance),
ALLOW_NETHER("world.allow-nether", true, Migrate.PROPS, "allow-nether",
Boolean.class::isInstance),
ALLOW_END("world.allow-end", true, Migrate.BUKKIT, "settings.allow-end",
Boolean.class::isInstance),
PERSIST_SPAWN("world.keep-spawn-loaded", true,
Boolean.class::isInstance),
POPULATE_ANCHORED_CHUNKS("world.populate-anchored-chunks", true,
Boolean.class::isInstance),
WATER_CLASSIC("world.classic-style-water", false,
Boolean.class::isInstance),
DISABLE_GENERATION("world.disable-generation", false,
Boolean.class::isInstance),
// libraries
LIBRARY_CHECKSUM_VALIDATION("libraries.checksum-validation", true,
Boolean.class::isInstance),
LIBRARY_REPOSITORY_URL("libraries.repository-url",
"https://repo.glowstone.net/repository/maven-public/",
String.class::isInstance),
LIBRARY_DOWNLOAD_ATTEMPTS("libraries.download-attempts", 2,
Validators.POSITIVE_INTEGER),
COMPATIBILITY_BUNDLE("libraries.compatibility-bundle",
CompatibilityBundle.CRAFTBUKKIT.name(), String.class::isInstance),
LIBRARIES_LIST("libraries.list", new ArrayList<>());
@Getter
private final String path;
private final Object def;
private final Migrate migrate;
private final String migratePath;
private final Predicate validator;
Key(String path, Object def) {
this(path, def, null, null);
}
Key(String path, Object def, Predicate<?> validator) {
this(path, def, null, null, validator);
}
Key(String path, Object def, Migrate migrate, String migratePath) {
this(path, def, migrate, migratePath, null);
}
Key(String path, Object def, Migrate migrate, String migratePath, Predicate<?> validator) {
this.path = path;
this.def = def;
this.migrate = migrate;
this.migratePath = migratePath;
this.validator = validator;
}
@Override
public String toString() {
return name() + "(" + path + ", " + def + ")";
}
}
/**
* A predicate wrapper to check if a value is a valid element of an enum.
*
* <p>See {@link Validators#forEnum(Class)}
*
* @param <T> the type of the enum
*/
static final class EnumPredicate<T extends Enum<T>> implements Predicate<String> {
final Class<T> enumClass;
EnumPredicate(Class<T> enumClass) {
checkNotNull(enumClass);
checkArgument(enumClass.isEnum());
this.enumClass = enumClass;
}
@Override
public boolean test(String value) {
if (!typeCheck(String.class).test(value)) {
return false;
}
if (value == null || value.isEmpty()) {
return false;
}
try {
Enum.valueOf(enumClass, value);
} catch (Exception e) {
return false;
}
return true;
}
}
static class Validators {
/**
* Checks if the value is positive (over zero).
*/
static final Predicate<Number> POSITIVE = (number) -> number.doubleValue() > 0;
/**
* Checks if the value is integer-typed and positive.
*/
static final Predicate<Integer> POSITIVE_INTEGER = typeCheck(Integer.class).and(
POSITIVE);
/**
* Checks if the value is zero.
*/
static final Predicate<Number> ZERO = (number) -> number.doubleValue() == 0;
/**
* Checks if the value is greater than (positive) or equal to zero.
*/
static final Predicate<Number> ABSOLUTE = POSITIVE.or(ZERO);
/**
* Checks if the value is integer-typed and either positive or zero.
*/
static final Predicate<?> NON_NEGATIVE_INTEGER = typeCheck(Integer.class).and(ABSOLUTE);
/**
* Checks if the value is a valid port number.
*/
static final Predicate<Integer> PORT = typeCheck(Integer.class)
.and(POSITIVE).and((number) -> number < 49152);
/**
* Checks if the value is a valid {@link WorldType} name.
*/
static final Predicate<String> WORLD_TYPE = typeCheck(String.class)
.and((value) -> WorldType.getByName(value) != null);
/**
* Creates a {@link EnumPredicate} that checks if the value is a member of the given enum
* class.
*
* @param enumClass the enum class
* @param <T> the type of the enum
* @return the predicate
*/
static <T extends Enum<T>> EnumPredicate<T> forEnum(Class<T> enumClass) {
return new EnumPredicate<>(enumClass);
}
/**
* Checks if the value is a valid file/directory path.
*
* <p>Note that the behavior of this predicate may be platform-dependent.
*/
static final Predicate<String> PATH = typeCheck(String.class).and((value) -> {
try {
if (Paths.get(value) == null) {
return false;
}
} catch (Exception ex) {
return false;
}
return true;
});
/**
* Creates a {@link Predicate} that checks if the value is an instance of the
* specified class.
*
* @param expected the expected class.
* @return the predicate
*/
static <T> Predicate<T> typeCheck(Class<T> expected) {
return expected::isInstance;
}
}
private enum Migrate {
BUKKIT, PROPS
}
}