2020-04-15 14:22:03 +02:00
|
|
|
package com.minelittlepony.unicopia;
|
2018-09-12 01:29:49 +02:00
|
|
|
|
2022-12-10 00:55:53 +01:00
|
|
|
import java.util.*;
|
2024-04-08 21:45:46 +02:00
|
|
|
import java.util.function.Function;
|
2022-12-10 00:55:53 +01:00
|
|
|
import java.util.function.Predicate;
|
2022-10-17 17:28:13 +02:00
|
|
|
import java.util.stream.Collectors;
|
2018-09-12 22:37:06 +02:00
|
|
|
|
2021-08-04 15:38:03 +02:00
|
|
|
import org.jetbrains.annotations.Nullable;
|
2018-09-12 01:29:49 +02:00
|
|
|
import com.google.common.base.Strings;
|
2024-04-08 21:45:46 +02:00
|
|
|
import com.minelittlepony.unicopia.ability.Abilities;
|
|
|
|
import com.minelittlepony.unicopia.ability.Ability;
|
2020-09-23 17:19:28 +02:00
|
|
|
import com.minelittlepony.unicopia.ability.magic.Affine;
|
2022-12-19 00:12:49 +01:00
|
|
|
import com.minelittlepony.unicopia.util.RegistryUtils;
|
2022-08-27 15:51:49 +02:00
|
|
|
import com.mojang.brigadier.context.CommandContext;
|
|
|
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
|
|
|
import com.mojang.brigadier.exceptions.DynamicCommandExceptionType;
|
2024-04-08 21:45:46 +02:00
|
|
|
import com.mojang.serialization.Codec;
|
|
|
|
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
2018-09-12 01:29:49 +02:00
|
|
|
|
2022-08-27 15:07:29 +02:00
|
|
|
import net.minecraft.command.argument.RegistryKeyArgumentType;
|
2020-04-15 18:12:00 +02:00
|
|
|
import net.minecraft.entity.player.PlayerEntity;
|
2022-08-27 15:51:49 +02:00
|
|
|
import net.minecraft.server.command.ServerCommandSource;
|
2024-02-12 20:36:43 +01:00
|
|
|
import net.minecraft.server.network.ServerPlayerEntity;
|
2021-02-09 19:43:19 +01:00
|
|
|
import net.minecraft.text.Text;
|
2020-09-23 21:56:57 +02:00
|
|
|
import net.minecraft.util.Identifier;
|
2024-04-08 21:45:46 +02:00
|
|
|
import net.minecraft.util.Util;
|
2022-12-18 22:07:24 +01:00
|
|
|
import net.minecraft.registry.Registry;
|
|
|
|
import net.minecraft.registry.RegistryKey;
|
2022-08-27 15:07:29 +02:00
|
|
|
|
2024-04-08 21:45:46 +02:00
|
|
|
public record Race (
|
|
|
|
List<Ability<?>> abilities,
|
|
|
|
Affinity affinity,
|
|
|
|
Availability availability,
|
|
|
|
FlightType flightType,
|
|
|
|
boolean canCast,
|
|
|
|
boolean hasIronGut,
|
|
|
|
boolean canUseEarth,
|
|
|
|
boolean isNocturnal,
|
|
|
|
boolean canHang,
|
|
|
|
boolean isFish,
|
|
|
|
boolean canInfluenceWeather,
|
|
|
|
boolean canInteractWithClouds
|
|
|
|
) implements Affine {
|
2023-01-21 01:28:59 +01:00
|
|
|
public static final String DEFAULT_ID = "unicopia:unset";
|
2022-12-19 00:12:49 +01:00
|
|
|
public static final Registry<Race> REGISTRY = RegistryUtils.createDefaulted(Unicopia.id("race"), DEFAULT_ID);
|
2023-11-09 17:26:26 +01:00
|
|
|
public static final Registry<Race> COMMAND_REGISTRY = RegistryUtils.createDefaulted(Unicopia.id("race/grantable"), DEFAULT_ID);
|
2022-08-27 15:07:29 +02:00
|
|
|
public static final RegistryKey<? extends Registry<Race>> REGISTRY_KEY = REGISTRY.getKey();
|
2024-04-09 12:50:50 +02:00
|
|
|
private static final DynamicCommandExceptionType UNKNOWN_RACE_EXCEPTION = new DynamicCommandExceptionType(id -> Text.translatable("commands.race.fail", id));
|
2024-04-08 21:45:46 +02:00
|
|
|
private static final Function<Race, Composite> COMPOSITES = Util.memoize(race -> new Composite(race, null, null));
|
|
|
|
|
|
|
|
public static final Codec<Race> CODEC = RecordCodecBuilder.create(i -> i.group(
|
|
|
|
Abilities.REGISTRY.getCodec().listOf().fieldOf("abilities").forGetter(Race::abilities),
|
|
|
|
Affinity.CODEC.fieldOf("affinity").forGetter(Race::affinity),
|
|
|
|
Availability.CODEC.fieldOf("availability").forGetter(Race::availability),
|
|
|
|
FlightType.CODEC.fieldOf("flight").forGetter(Race::flightType),
|
|
|
|
Codec.BOOL.fieldOf("magic").forGetter(Race::canCast),
|
|
|
|
Codec.BOOL.fieldOf("can_forage").forGetter(Race::hasIronGut),
|
|
|
|
Codec.BOOL.fieldOf("earth_pony_strength").forGetter(Race::canUseEarth),
|
|
|
|
Codec.BOOL.fieldOf("nocturnal").forGetter(Race::isNocturnal),
|
|
|
|
Codec.BOOL.fieldOf("hanging").forGetter(Race::canHang),
|
|
|
|
Codec.BOOL.fieldOf("aquatic").forGetter(Race::isFish),
|
|
|
|
Codec.BOOL.fieldOf("weather_magic").forGetter(Race::canInfluenceWeather),
|
|
|
|
Codec.BOOL.fieldOf("cloud_magic").forGetter(Race::canInteractWithClouds)
|
|
|
|
).apply(i, Race::new));
|
2020-04-15 18:12:00 +02:00
|
|
|
|
2019-01-29 13:13:06 +01:00
|
|
|
/**
|
|
|
|
* The default, unset race.
|
|
|
|
* This is used if there are no other races.
|
|
|
|
*/
|
2024-04-08 21:45:46 +02:00
|
|
|
public static final Race UNSET = register("unset", new Builder().availability(Availability.COMMANDS));
|
|
|
|
public static final Race HUMAN = register("human", new Builder().availability(Availability.COMMANDS));
|
|
|
|
public static final Race EARTH = register("earth", new Builder().foraging().earth()
|
|
|
|
.abilities(Abilities.HUG, Abilities.STOMP, Abilities.KICK, Abilities.GROW)
|
|
|
|
);
|
|
|
|
public static final Race UNICORN = register("unicorn", new Builder().foraging().magic()
|
2024-04-08 23:13:08 +02:00
|
|
|
.abilities(Abilities.TELEPORT, Abilities.CAST, Abilities.GROUP_TELEPORT, Abilities.SHOOT, Abilities.DISPELL)
|
2024-04-08 21:45:46 +02:00
|
|
|
);
|
|
|
|
public static final Race PEGASUS = register("pegasus", new Builder().foraging().flight(FlightType.AVIAN).weatherMagic().cloudMagic()
|
|
|
|
.abilities(Abilities.TOGGLE_FLIGHT, Abilities.RAINBOOM, Abilities.CAPTURE_CLOUD, Abilities.CARRY)
|
|
|
|
);
|
2024-04-27 13:21:47 +02:00
|
|
|
public static final Race BAT = register("bat", new Builder().foraging().flight(FlightType.AVIAN).canHang().cloudMagic().nocturnal()
|
2024-04-08 21:45:46 +02:00
|
|
|
.abilities(Abilities.TOGGLE_FLIGHT, Abilities.CARRY, Abilities.HANG, Abilities.EEEE)
|
|
|
|
);
|
|
|
|
public static final Race ALICORN = register("alicorn", new Builder().foraging().availability(Availability.COMMANDS).flight(FlightType.AVIAN).earth().magic().weatherMagic().cloudMagic()
|
|
|
|
.abilities(
|
2024-04-08 23:13:08 +02:00
|
|
|
Abilities.TELEPORT, Abilities.GROUP_TELEPORT, Abilities.CAST, Abilities.SHOOT, Abilities.DISPELL,
|
2024-04-08 21:45:46 +02:00
|
|
|
Abilities.TOGGLE_FLIGHT, Abilities.RAINBOOM, Abilities.CAPTURE_CLOUD, Abilities.CARRY,
|
|
|
|
Abilities.HUG, Abilities.STOMP, Abilities.KICK, Abilities.GROW,
|
|
|
|
Abilities.TIME
|
|
|
|
)
|
|
|
|
);
|
|
|
|
public static final Race CHANGELING = register("changeling", new Builder().foraging().affinity(Affinity.BAD).flight(FlightType.INSECTOID).canHang()
|
|
|
|
.abilities(Abilities.DISPELL, Abilities.TOGGLE_FLIGHT, Abilities.FEED, Abilities.DISGUISE, Abilities.CARRY)
|
|
|
|
);
|
|
|
|
public static final Race KIRIN = register("kirin", new Builder().foraging().magic()
|
|
|
|
.abilities(Abilities.DISPELL, Abilities.RAGE, Abilities.NIRIK_BLAST, Abilities.KIRIN_CAST)
|
|
|
|
);
|
|
|
|
public static final Race HIPPOGRIFF = register("hippogriff", new Builder().foraging().flight(FlightType.AVIAN).cloudMagic()
|
|
|
|
.abilities(Abilities.TOGGLE_FLIGHT, Abilities.SCREECH, Abilities.PECK, Abilities.DASH, Abilities.CARRY)
|
|
|
|
);
|
2024-04-09 13:34:30 +02:00
|
|
|
public static final Race SEAPONY = register("seapony", new Builder().availability(Availability.COMMANDS).foraging().fish()
|
2024-04-08 21:45:46 +02:00
|
|
|
.abilities(Abilities.SONAR_PULSE)
|
|
|
|
);
|
2022-08-27 15:07:29 +02:00
|
|
|
|
|
|
|
public static void bootstrap() {}
|
2018-09-12 01:29:49 +02:00
|
|
|
|
2023-09-03 12:06:44 +02:00
|
|
|
public Composite composite() {
|
2024-04-08 21:45:46 +02:00
|
|
|
return COMPOSITES.apply(this);
|
2023-09-03 12:06:44 +02:00
|
|
|
}
|
|
|
|
|
2023-11-09 17:26:26 +01:00
|
|
|
public Composite composite(@Nullable Race pseudo, @Nullable Race potential) {
|
|
|
|
return pseudo == null && potential == null ? composite() : new Composite(this, pseudo, potential);
|
2023-09-03 12:06:44 +02:00
|
|
|
}
|
|
|
|
|
2020-09-23 17:19:28 +02:00
|
|
|
@Override
|
|
|
|
public Affinity getAffinity() {
|
2024-04-08 21:45:46 +02:00
|
|
|
return affinity;
|
2020-04-27 18:09:19 +02:00
|
|
|
}
|
|
|
|
|
2023-01-21 01:28:59 +01:00
|
|
|
public boolean isUnset() {
|
|
|
|
return this == UNSET;
|
2020-01-27 11:05:22 +01:00
|
|
|
}
|
|
|
|
|
2023-01-21 01:28:59 +01:00
|
|
|
public boolean isEquine() {
|
|
|
|
return !isHuman();
|
|
|
|
}
|
|
|
|
|
|
|
|
public boolean isHuman() {
|
2024-04-08 21:45:46 +02:00
|
|
|
return isUnset() || this == HUMAN;
|
2018-09-12 01:29:49 +02:00
|
|
|
}
|
|
|
|
|
2023-05-21 18:39:02 +02:00
|
|
|
public boolean isDayurnal() {
|
|
|
|
return !isNocturnal();
|
|
|
|
}
|
|
|
|
|
2020-10-08 09:39:30 +02:00
|
|
|
public boolean canFly() {
|
2023-04-30 02:41:21 +02:00
|
|
|
return !flightType().isGrounded();
|
2018-09-12 01:29:49 +02:00
|
|
|
}
|
|
|
|
|
2024-03-24 00:40:00 +01:00
|
|
|
public boolean hasPersistentWeatherMagic() {
|
|
|
|
return canInfluenceWeather();
|
|
|
|
}
|
|
|
|
|
2024-04-08 21:45:46 +02:00
|
|
|
public boolean canUse(Ability<?> ability) {
|
|
|
|
return abilities.contains(ability);
|
|
|
|
}
|
|
|
|
|
2022-10-17 17:28:13 +02:00
|
|
|
public Identifier getId() {
|
2023-10-08 00:48:23 +02:00
|
|
|
return REGISTRY.getId(this);
|
2022-10-17 17:28:13 +02:00
|
|
|
}
|
|
|
|
|
2021-02-09 19:43:19 +01:00
|
|
|
public Text getDisplayName() {
|
2022-06-25 00:19:55 +02:00
|
|
|
return Text.translatable(getTranslationKey());
|
2021-02-09 19:43:19 +01:00
|
|
|
}
|
|
|
|
|
2021-08-06 01:06:57 +02:00
|
|
|
public Text getAltDisplayName() {
|
2022-06-25 00:19:55 +02:00
|
|
|
return Text.translatable(getTranslationKey() + ".alt");
|
2021-08-06 01:06:57 +02:00
|
|
|
}
|
|
|
|
|
2019-01-31 16:21:14 +01:00
|
|
|
public String getTranslationKey() {
|
2024-04-08 21:45:46 +02:00
|
|
|
return Util.createTranslationKey("race", getId());
|
2018-09-12 01:29:49 +02:00
|
|
|
}
|
|
|
|
|
2022-09-18 14:16:28 +02:00
|
|
|
public Identifier getIcon() {
|
2024-04-08 21:45:46 +02:00
|
|
|
return getId().withPath(p -> "textures/gui/race/" + p + ".png");
|
2022-09-18 14:16:28 +02:00
|
|
|
}
|
|
|
|
|
2020-09-28 20:18:10 +02:00
|
|
|
public boolean isPermitted(@Nullable PlayerEntity sender) {
|
2024-02-13 22:58:19 +01:00
|
|
|
return AllowList.INSTANCE.permits(this);
|
2020-04-15 18:12:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
public Race validate(PlayerEntity sender) {
|
|
|
|
if (!isPermitted(sender)) {
|
2024-02-12 20:36:43 +01:00
|
|
|
Race alternative = this == EARTH ? HUMAN : EARTH.validate(sender);
|
|
|
|
if (alternative != this && sender instanceof ServerPlayerEntity spe) {
|
|
|
|
spe.sendMessageToClient(Text.translatable("respawn.reason.illegal_race", getDisplayName()), false);
|
2020-04-15 18:12:00 +02:00
|
|
|
}
|
2024-02-12 20:36:43 +01:00
|
|
|
return alternative;
|
2020-04-15 18:12:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2023-11-09 17:26:26 +01:00
|
|
|
public Race or(Race other) {
|
|
|
|
return isEquine() ? this : other;
|
|
|
|
}
|
|
|
|
|
2022-10-17 17:28:13 +02:00
|
|
|
@Override
|
|
|
|
public String toString() {
|
|
|
|
return "Race{ " + getId().toString() + " }";
|
|
|
|
}
|
|
|
|
|
2019-02-09 13:26:03 +01:00
|
|
|
public boolean equals(String s) {
|
2022-10-17 17:28:13 +02:00
|
|
|
return getId().toString().equalsIgnoreCase(s)
|
2019-01-31 16:21:14 +01:00
|
|
|
|| getTranslationKey().equalsIgnoreCase(s);
|
2018-09-12 01:29:49 +02:00
|
|
|
}
|
|
|
|
|
2018-09-16 00:45:44 +02:00
|
|
|
public static Race fromName(String s, Race def) {
|
2018-09-12 01:29:49 +02:00
|
|
|
if (!Strings.isNullOrEmpty(s)) {
|
2022-08-27 15:07:29 +02:00
|
|
|
Identifier id = Identifier.tryParse(s);
|
|
|
|
if (id != null) {
|
|
|
|
if (id.getNamespace() == Identifier.DEFAULT_NAMESPACE) {
|
|
|
|
id = new Identifier(Unicopia.DEFAULT_NAMESPACE, id.getPath());
|
|
|
|
}
|
|
|
|
return REGISTRY.getOrEmpty(id).orElse(def);
|
2018-09-12 01:29:49 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-09-16 00:45:44 +02:00
|
|
|
return def;
|
2018-09-12 01:29:49 +02:00
|
|
|
}
|
|
|
|
|
2024-04-08 21:45:46 +02:00
|
|
|
public static Race register(String name, Builder builder) {
|
|
|
|
return register(Unicopia.id(name), builder);
|
|
|
|
}
|
|
|
|
|
|
|
|
public static Race register(Identifier id, Builder builder) {
|
|
|
|
Race race = Registry.register(REGISTRY, id, builder.build());
|
|
|
|
if (race.availability().isGrantable()) {
|
|
|
|
Registry.register(COMMAND_REGISTRY, id, race);
|
|
|
|
}
|
|
|
|
return race;
|
|
|
|
}
|
|
|
|
|
|
|
|
public static RegistryKeyArgumentType<Race> argument() {
|
|
|
|
return RegistryKeyArgumentType.registryKey(COMMAND_REGISTRY.getKey());
|
2019-01-29 13:13:06 +01:00
|
|
|
}
|
2022-08-27 15:51:49 +02:00
|
|
|
|
|
|
|
public static Race fromArgument(CommandContext<ServerCommandSource> context, String name) throws CommandSyntaxException {
|
|
|
|
Identifier id = context.getArgument(name, RegistryKey.class).getValue();
|
2024-04-09 12:56:20 +02:00
|
|
|
final Identifier idf = id;
|
|
|
|
if (id.getNamespace() == Identifier.DEFAULT_NAMESPACE && !REGISTRY.containsId(id)) {
|
2024-04-09 13:34:30 +02:00
|
|
|
id = new Identifier(REGISTRY_KEY.getValue().getNamespace(), id.getPath());
|
2024-04-09 12:56:20 +02:00
|
|
|
}
|
|
|
|
return REGISTRY.getOrEmpty(id).orElseThrow(() -> UNKNOWN_RACE_EXCEPTION.create(idf));
|
2022-08-27 15:51:49 +02:00
|
|
|
}
|
2022-10-17 17:28:13 +02:00
|
|
|
|
|
|
|
public static Set<Race> allPermitted(PlayerEntity player) {
|
|
|
|
return REGISTRY.stream().filter(r -> r.isPermitted(player)).collect(Collectors.toSet());
|
|
|
|
}
|
2022-12-10 00:55:53 +01:00
|
|
|
|
2023-11-09 17:26:26 +01:00
|
|
|
public record Composite (Race physical, @Nullable Race pseudo, @Nullable Race potential) {
|
2023-09-01 20:09:13 +02:00
|
|
|
public Race collapsed() {
|
|
|
|
return pseudo == null ? physical : pseudo;
|
|
|
|
}
|
|
|
|
|
2022-12-10 00:55:53 +01:00
|
|
|
public boolean includes(Race race) {
|
|
|
|
return physical == race || pseudo == race;
|
|
|
|
}
|
|
|
|
|
|
|
|
public boolean any(Predicate<Race> test) {
|
2023-08-29 16:17:44 +02:00
|
|
|
return test.test(physical) || (pseudo != null && test.test(pseudo));
|
2022-12-10 00:55:53 +01:00
|
|
|
}
|
2023-09-01 20:09:13 +02:00
|
|
|
|
|
|
|
public boolean canUseEarth() {
|
|
|
|
return any(Race::canUseEarth);
|
|
|
|
}
|
|
|
|
|
|
|
|
public boolean canFly() {
|
|
|
|
return any(Race::canFly);
|
|
|
|
}
|
|
|
|
|
|
|
|
public boolean canCast() {
|
|
|
|
return any(Race::canCast);
|
|
|
|
}
|
2023-10-08 00:44:30 +02:00
|
|
|
|
2024-04-08 21:45:46 +02:00
|
|
|
public boolean canUse(Ability<?> ability) {
|
|
|
|
return any(r -> r.canUse(ability));
|
|
|
|
}
|
|
|
|
|
2024-03-24 00:40:00 +01:00
|
|
|
public boolean canInteractWithClouds() {
|
|
|
|
return any(Race::canInteractWithClouds);
|
|
|
|
}
|
|
|
|
|
|
|
|
public boolean canInfluenceWeather() {
|
|
|
|
return any(Race::canInfluenceWeather);
|
|
|
|
}
|
|
|
|
|
|
|
|
public boolean hasPersistentWeatherMagic() {
|
|
|
|
return any(Race::hasPersistentWeatherMagic);
|
|
|
|
}
|
|
|
|
|
2023-10-08 00:44:30 +02:00
|
|
|
public FlightType flightType() {
|
2023-10-09 16:14:28 +02:00
|
|
|
if (pseudo() == null) {
|
|
|
|
return physical().flightType();
|
|
|
|
}
|
2023-10-08 00:44:30 +02:00
|
|
|
return physical().flightType().or(pseudo().flightType());
|
|
|
|
}
|
2022-12-10 00:55:53 +01:00
|
|
|
}
|
2024-04-08 21:45:46 +02:00
|
|
|
|
|
|
|
public static final class Builder {
|
|
|
|
private final List<Ability<?>> abilities = new ArrayList<>();
|
|
|
|
private Affinity affinity = Affinity.NEUTRAL;
|
|
|
|
private Availability availability = Availability.DEFAULT;
|
|
|
|
private boolean canCast;
|
|
|
|
private boolean hasIronGut;
|
|
|
|
private FlightType flightType = FlightType.NONE;
|
|
|
|
private boolean canUseEarth;
|
|
|
|
private boolean isNocturnal;
|
|
|
|
private boolean canHang;
|
|
|
|
private boolean isFish;
|
|
|
|
private boolean canInfluenceWeather;
|
|
|
|
private boolean canInteractWithClouds;
|
|
|
|
|
|
|
|
public Builder abilities(Ability<?>...abilities) {
|
|
|
|
this.abilities.addAll(List.of(abilities));
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder foraging() {
|
|
|
|
hasIronGut = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder affinity(Affinity affinity) {
|
|
|
|
this.affinity = affinity;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder availability(Availability availability) {
|
|
|
|
this.availability = availability;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder flight(FlightType flight) {
|
|
|
|
flightType = flight;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder magic() {
|
|
|
|
canCast = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder earth() {
|
|
|
|
canUseEarth = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder nocturnal() {
|
|
|
|
isNocturnal = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder canHang() {
|
|
|
|
canHang = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder fish() {
|
|
|
|
isFish = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder weatherMagic() {
|
|
|
|
canInfluenceWeather = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Builder cloudMagic() {
|
|
|
|
canInteractWithClouds = true;
|
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
|
|
|
public Race build() {
|
|
|
|
return new Race(List.copyOf(abilities), affinity, availability, flightType, canCast, hasIronGut, canUseEarth, isNocturnal, canHang, isFish, canInfluenceWeather, canInteractWithClouds);
|
|
|
|
}
|
|
|
|
}
|
2018-09-12 01:29:49 +02:00
|
|
|
}
|
2022-08-27 15:51:49 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|