Commit 67ac0d70 authored by Akka's avatar Akka Committed by Melledy
Browse files

add region entity

parent 1c6c5813
package emu.grasscutter.game.entity;
import emu.grasscutter.game.props.EntityIdType;
import emu.grasscutter.game.world.Scene;
import emu.grasscutter.net.proto.SceneEntityInfoOuterClass;
import emu.grasscutter.scripts.data.SceneRegion;
import emu.grasscutter.utils.Position;
import it.unimi.dsi.fastutil.ints.Int2FloatOpenHashMap;
import lombok.Getter;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Getter
public class EntityRegion extends GameEntity{
private final Position position;
private boolean hasNewEntities;
private final Set<Integer> entities; // Ids of entities inside this region
private final SceneRegion metaRegion;
public EntityRegion(Scene scene, SceneRegion region) {
super(scene);
this.id = getScene().getWorld().getNextEntityId(EntityIdType.REGION);
setGroupId(region.group.id);
setBlockId(region.group.block_id);
setConfigId(region.config_id);
this.position = region.pos.clone();
this.entities = ConcurrentHashMap.newKeySet();
this.metaRegion = region;
}
public void addEntity(GameEntity entity) {
if (this.getEntities().contains(entity.getId())) {
return;
}
this.getEntities().add(entity.getId());
this.hasNewEntities = true;
}
public boolean hasNewEntities() {
return hasNewEntities;
}
public void resetNewEntities() {
hasNewEntities = false;
}
public void removeEntity(GameEntity entity) {
this.getEntities().remove(entity.getId());
}
@Override
public Int2FloatOpenHashMap getFightProperties() {
return null;
}
@Override
public Position getPosition() {
return position;
}
@Override
public Position getRotation() {
return null;
}
@Override
public SceneEntityInfoOuterClass.SceneEntityInfo toProto() {
/**
* The Region Entity would not be sent to client.
*/
return null;
}
}
...@@ -5,6 +5,7 @@ public enum EntityIdType { ...@@ -5,6 +5,7 @@ public enum EntityIdType {
MONSTER (0x02), MONSTER (0x02),
NPC (0x03), NPC (0x03),
GADGET (0x04), GADGET (0x04),
REGION (0x05),
WEAPON (0x06), WEAPON (0x06),
TEAM (0x09), TEAM (0x09),
MPLEVEL (0x0b); MPLEVEL (0x0b);
......
...@@ -30,14 +30,15 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; ...@@ -30,14 +30,15 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import org.danilopianini.util.SpatialIndex; import org.danilopianini.util.SpatialIndex;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class Scene { public class Scene {
private final World world; private final World world;
private final SceneData sceneData; private final SceneData sceneData;
private final List<Player> players; private final List<Player> players;
private final Int2ObjectMap<GameEntity> entities; private final Map<Integer, GameEntity> entities;
private final Set<SpawnDataEntry> spawnedEntities; private final Set<SpawnDataEntry> spawnedEntities;
private final Set<SpawnDataEntry> deadSpawnedEntities; private final Set<SpawnDataEntry> deadSpawnedEntities;
private final Set<SceneBlock> loadedBlocks; private final Set<SceneBlock> loadedBlocks;
...@@ -57,16 +58,16 @@ public class Scene { ...@@ -57,16 +58,16 @@ public class Scene {
public Scene(World world, SceneData sceneData) { public Scene(World world, SceneData sceneData) {
this.world = world; this.world = world;
this.sceneData = sceneData; this.sceneData = sceneData;
this.players = Collections.synchronizedList(new ArrayList<>()); this.players = new CopyOnWriteArrayList<>();
this.entities = Int2ObjectMaps.synchronize(new Int2ObjectOpenHashMap<>()); this.entities = new ConcurrentHashMap<>();
this.time = 8 * 60; this.time = 8 * 60;
this.climate = ClimateType.CLIMATE_SUNNY; this.climate = ClimateType.CLIMATE_SUNNY;
this.prevScene = 3; this.prevScene = 3;
this.spawnedEntities = new HashSet<>(); this.spawnedEntities = ConcurrentHashMap.newKeySet();
this.deadSpawnedEntities = new HashSet<>(); this.deadSpawnedEntities = ConcurrentHashMap.newKeySet();
this.loadedBlocks = new HashSet<>(); this.loadedBlocks = ConcurrentHashMap.newKeySet();
this.scriptManager = new SceneScriptManager(this); this.scriptManager = new SceneScriptManager(this);
} }
...@@ -94,7 +95,7 @@ public class Scene { ...@@ -94,7 +95,7 @@ public class Scene {
return this.getPlayers().size(); return this.getPlayers().size();
} }
public Int2ObjectMap<GameEntity> getEntities() { public Map<Integer, GameEntity> getEntities() {
return entities; return entities;
} }
...@@ -629,15 +630,10 @@ public class Scene { ...@@ -629,15 +630,10 @@ public class Scene {
var suiteData = group.getSuiteByIndex(suite); var suiteData = group.getSuiteByIndex(suite);
suiteData.sceneTriggers.forEach(getScriptManager()::registerTrigger); suiteData.sceneTriggers.forEach(getScriptManager()::registerTrigger);
entities.addAll(suiteData.sceneGadgets.stream() entities.addAll(scriptManager.getGadgetsInGroupSuite(group, suiteData));
.map(g -> scriptManager.createGadget(group.id, group.block_id, g)) entities.addAll(scriptManager.getMonstersInGroupSuite(group, suiteData));
.filter(Objects::nonNull)
.toList());
entities.addAll(suiteData.sceneMonsters.stream()
.map(mob -> scriptManager.createMonster(group.id, group.block_id, mob))
.filter(Objects::nonNull)
.toList());
scriptManager.registerRegionInGroupSuite(group, suiteData);
} }
scriptManager.meetEntities(entities); scriptManager.meetEntities(entities);
...@@ -660,13 +656,12 @@ public class Scene { ...@@ -660,13 +656,12 @@ public class Scene {
group.triggers.values().forEach(getScriptManager()::deregisterTrigger); group.triggers.values().forEach(getScriptManager()::deregisterTrigger);
} }
if(group.regions != null){ if(group.regions != null){
group.regions.forEach(getScriptManager()::deregisterRegion); group.regions.values().forEach(getScriptManager()::deregisterRegion);
} }
} }
scriptManager.getLoadedGroupSetPerBlock().remove(block.id); scriptManager.getLoadedGroupSetPerBlock().remove(block.id);
Grasscutter.getLogger().info("Scene {} Block {} is unloaded.", this.getId(), block.id); Grasscutter.getLogger().info("Scene {} Block {} is unloaded.", this.getId(), block.id);
} }
// Gadgets // Gadgets
public void onPlayerCreateGadget(EntityClientGadget gadget) { public void onPlayerCreateGadget(EntityClientGadget gadget) {
......
...@@ -6,10 +6,7 @@ import emu.grasscutter.Grasscutter; ...@@ -6,10 +6,7 @@ import emu.grasscutter.Grasscutter;
import emu.grasscutter.data.GameData; import emu.grasscutter.data.GameData;
import emu.grasscutter.data.excels.MonsterData; import emu.grasscutter.data.excels.MonsterData;
import emu.grasscutter.data.excels.WorldLevelData; import emu.grasscutter.data.excels.WorldLevelData;
import emu.grasscutter.game.entity.EntityGadget; import emu.grasscutter.game.entity.*;
import emu.grasscutter.game.entity.EntityMonster;
import emu.grasscutter.game.entity.EntityNPC;
import emu.grasscutter.game.entity.GameEntity;
import emu.grasscutter.game.world.Scene; import emu.grasscutter.game.world.Scene;
import emu.grasscutter.net.proto.VisionTypeOuterClass; import emu.grasscutter.net.proto.VisionTypeOuterClass;
import emu.grasscutter.scripts.constants.EventType; import emu.grasscutter.scripts.constants.EventType;
...@@ -17,17 +14,12 @@ import emu.grasscutter.scripts.data.*; ...@@ -17,17 +14,12 @@ import emu.grasscutter.scripts.data.*;
import emu.grasscutter.scripts.service.ScriptMonsterSpawnService; import emu.grasscutter.scripts.service.ScriptMonsterSpawnService;
import emu.grasscutter.scripts.service.ScriptMonsterTideService; import emu.grasscutter.scripts.service.ScriptMonsterTideService;
import io.netty.util.concurrent.FastThreadLocalThread; import io.netty.util.concurrent.FastThreadLocalThread;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import org.luaj.vm2.LuaError; import org.luaj.vm2.LuaError;
import org.luaj.vm2.LuaValue; import org.luaj.vm2.LuaValue;
import org.luaj.vm2.lib.jse.CoerceJavaToLua; import org.luaj.vm2.lib.jse.CoerceJavaToLua;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutorService; import java.util.concurrent.*;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class SceneScriptManager { public class SceneScriptManager {
...@@ -38,15 +30,15 @@ public class SceneScriptManager { ...@@ -38,15 +30,15 @@ public class SceneScriptManager {
/** /**
* current triggers controlled by RefreshGroup * current triggers controlled by RefreshGroup
*/ */
private final Int2ObjectOpenHashMap<Set<SceneTrigger>> currentTriggers; private final Map<Integer, Set<SceneTrigger>> currentTriggers;
private final Int2ObjectOpenHashMap<SceneRegion> regions; private final Map<Integer, EntityRegion> regions; // <EntityId-Region>
private Map<Integer,SceneGroup> sceneGroups; private final Map<Integer,SceneGroup> sceneGroups;
private ScriptMonsterTideService scriptMonsterTideService; private ScriptMonsterTideService scriptMonsterTideService;
private ScriptMonsterSpawnService scriptMonsterSpawnService; private ScriptMonsterSpawnService scriptMonsterSpawnService;
/** /**
* blockid - loaded groupSet * blockid - loaded groupSet
*/ */
private Int2ObjectMap<Set<SceneGroup>> loadedGroupSetPerBlock; private final Map<Integer, Set<SceneGroup>> loadedGroupSetPerBlock;
public static final ExecutorService eventExecutor; public static final ExecutorService eventExecutor;
static { static {
eventExecutor = new ThreadPoolExecutor(4, 4, eventExecutor = new ThreadPoolExecutor(4, 4,
...@@ -55,13 +47,13 @@ public class SceneScriptManager { ...@@ -55,13 +47,13 @@ public class SceneScriptManager {
} }
public SceneScriptManager(Scene scene) { public SceneScriptManager(Scene scene) {
this.scene = scene; this.scene = scene;
this.currentTriggers = new Int2ObjectOpenHashMap<>(); this.currentTriggers = new ConcurrentHashMap<>();
this.regions = new Int2ObjectOpenHashMap<>(); this.regions = new ConcurrentHashMap<>();
this.variables = new HashMap<>(); this.variables = new ConcurrentHashMap<>();
this.sceneGroups = new HashMap<>(); this.sceneGroups = new ConcurrentHashMap<>();
this.scriptMonsterSpawnService = new ScriptMonsterSpawnService(this); this.scriptMonsterSpawnService = new ScriptMonsterSpawnService(this);
this.loadedGroupSetPerBlock = new Int2ObjectOpenHashMap<>(); this.loadedGroupSetPerBlock = new ConcurrentHashMap<>();
// TEMPORARY // TEMPORARY
if (this.getScene().getId() < 10 && !Grasscutter.getConfig().server.game.enableScriptInBigWorld) { if (this.getScene().getId() < 10 && !Grasscutter.getConfig().server.game.enableScriptInBigWorld) {
...@@ -123,19 +115,25 @@ public class SceneScriptManager { ...@@ -123,19 +115,25 @@ public class SceneScriptManager {
spawnMonstersInGroup(group, suite); spawnMonstersInGroup(group, suite);
spawnGadgetsInGroup(group, suite); spawnGadgetsInGroup(group, suite);
} }
public SceneRegion getRegionById(int id) { public EntityRegion getRegionById(int id) {
return regions.get(id); return regions.get(id);
} }
public void registerRegion(SceneRegion region) { public void registerRegion(EntityRegion region) {
regions.put(region.config_id, region); regions.put(region.getId(), region);
} }
public void registerRegionInGroupSuite(SceneGroup group, SceneSuite suite){
public void deregisterRegion(SceneRegion region) { suite.sceneRegions.stream().map(region -> new EntityRegion(this.getScene(), region))
regions.remove(region.config_id); .forEach(this::registerRegion);
}
public synchronized void deregisterRegion(SceneRegion region) {
var instance = regions.values().stream()
.filter(r -> r.getConfigId() == region.config_id)
.findFirst();
instance.ifPresent(entityRegion -> regions.remove(entityRegion.getId()));
} }
public Int2ObjectMap<Set<SceneGroup>> getLoadedGroupSetPerBlock() { public Map<Integer, Set<SceneGroup>> getLoadedGroupSetPerBlock() {
return loadedGroupSetPerBlock; return loadedGroupSetPerBlock;
} }
...@@ -182,10 +180,6 @@ public class SceneScriptManager { ...@@ -182,10 +180,6 @@ public class SceneScriptManager {
} }
this.sceneGroups.put(group.id, group); this.sceneGroups.put(group.id, group);
if(group.regions != null){
group.regions.forEach(this::registerRegion);
}
} }
public void checkRegions() { public void checkRegions() {
...@@ -193,37 +187,49 @@ public class SceneScriptManager { ...@@ -193,37 +187,49 @@ public class SceneScriptManager {
return; return;
} }
for (SceneRegion region : this.regions.values()) { for (var region : this.regions.values()) {
getScene().getEntities().values() getScene().getEntities().values()
.stream() .stream()
.filter(e -> e.getEntityType() <= 2 && region.contains(e.getPosition())) .filter(e -> e.getEntityType() <= 2 && region.getMetaRegion().contains(e.getPosition()))
.forEach(region::addEntity); .forEach(region::addEntity);
if (region.hasNewEntities()) { if (region.hasNewEntities()) {
// This is not how it works, source_eid should be region entity id, but we dont have an entity for regions yet callEvent(EventType.EVENT_ENTER_REGION, new ScriptArgs(region.getConfigId()).setSourceEntityId(region.getId()));
callEvent(EventType.EVENT_ENTER_REGION, new ScriptArgs(region.config_id).setSourceEntityId(region.config_id));
region.resetNewEntities(); region.resetNewEntities();
} }
} }
} }
public List<EntityGadget> getGadgetsInGroupSuite(SceneGroup group, SceneSuite suite){
return suite.sceneGadgets.stream()
.map(g -> createGadget(group.id, group.block_id, g))
.filter(Objects::nonNull)
.toList();
}
public List<EntityMonster> getMonstersInGroupSuite(SceneGroup group, SceneSuite suite){
return suite.sceneMonsters.stream()
.map(mob -> createMonster(group.id, group.block_id, mob))
.filter(Objects::nonNull)
.toList();
}
public void addGroupSuite(SceneGroup group, SceneSuite suite){ public void addGroupSuite(SceneGroup group, SceneSuite suite){
spawnMonstersInGroup(group, suite); // we added trigger first
spawnGadgetsInGroup(group, suite);
registerTrigger(suite.sceneTriggers); registerTrigger(suite.sceneTriggers);
var toCreate = new ArrayList<GameEntity>();
toCreate.addAll(getGadgetsInGroupSuite(group, suite));
toCreate.addAll(getMonstersInGroupSuite(group, suite));
addEntities(toCreate);
registerRegionInGroupSuite(group, suite);
} }
public void removeGroupSuite(SceneGroup group, SceneSuite suite){ public void removeGroupSuite(SceneGroup group, SceneSuite suite){
deregisterTrigger(suite.sceneTriggers);
removeMonstersInGroup(group, suite); removeMonstersInGroup(group, suite);
removeGadgetsInGroup(group, suite); removeGadgetsInGroup(group, suite);
deregisterTrigger(suite.sceneTriggers);
}
public void spawnGadgetsInGroup(SceneGroup group, int suiteIndex) {
spawnGadgetsInGroup(group, group.getSuiteByIndex(suiteIndex));
}
public void spawnGadgetsInGroup(SceneGroup group) { suite.sceneRegions.forEach(this::deregisterRegion);
spawnGadgetsInGroup(group, null);
} }
public void spawnGadgetsInGroup(SceneGroup group, SceneSuite suite) { public void spawnGadgetsInGroup(SceneGroup group, SceneSuite suite) {
...@@ -240,13 +246,6 @@ public class SceneScriptManager { ...@@ -240,13 +246,6 @@ public class SceneScriptManager {
this.addEntities(toCreate); this.addEntities(toCreate);
} }
public void spawnMonstersInGroup(SceneGroup group, int suiteIndex) {
var suite = group.getSuiteByIndex(suiteIndex);
if(suite == null){
return;
}
spawnMonstersInGroup(group, suite);
}
public void spawnMonstersInGroup(SceneGroup group, SceneSuite suite) { public void spawnMonstersInGroup(SceneGroup group, SceneSuite suite) {
if(suite == null || suite.sceneMonsters.size() <= 0){ if(suite == null || suite.sceneMonsters.size() <= 0){
return; return;
...@@ -255,11 +254,6 @@ public class SceneScriptManager { ...@@ -255,11 +254,6 @@ public class SceneScriptManager {
.map(mob -> createMonster(group.id, group.block_id, mob)).toList()); .map(mob -> createMonster(group.id, group.block_id, mob)).toList());
} }
public void spawnMonstersInGroup(SceneGroup group) {
this.addEntities(group.monsters.values().stream()
.map(mob -> createMonster(group.id, group.block_id, mob)).toList());
}
public void startMonsterTideInGroup(SceneGroup group, Integer[] ordersConfigId, int tideCount, int sceneLimit) { public void startMonsterTideInGroup(SceneGroup group, Integer[] ordersConfigId, int tideCount, int sceneLimit) {
this.scriptMonsterTideService = this.scriptMonsterTideService =
new ScriptMonsterTideService(this, group, tideCount, sceneLimit, ordersConfigId); new ScriptMonsterTideService(this, group, tideCount, sceneLimit, ordersConfigId);
......
...@@ -316,13 +316,13 @@ public class ScriptLib { ...@@ -316,13 +316,13 @@ public class ScriptLib {
int regionId = table.get("region_eid").toint(); int regionId = table.get("region_eid").toint();
int entityType = table.get("entity_type").toint(); int entityType = table.get("entity_type").toint();
SceneRegion region = this.getSceneScriptManager().getRegionById(regionId); var region = this.getSceneScriptManager().getRegionById(regionId);
if (region == null) { if (region == null) {
return 0; return 0;
} }
return (int) region.getEntities().intStream().filter(e -> e >> 24 == entityType).count(); return (int) region.getEntities().stream().filter(e -> e >> 24 == entityType).count();
} }
public void PrintContextLog(String msg) { public void PrintContextLog(String msg) {
......
...@@ -34,7 +34,7 @@ public class SceneGroup { ...@@ -34,7 +34,7 @@ public class SceneGroup {
public Map<Integer, SceneGadget> gadgets; // <ConfigId, Gadgets> public Map<Integer, SceneGadget> gadgets; // <ConfigId, Gadgets>
public Map<String, SceneTrigger> triggers; public Map<String, SceneTrigger> triggers;
public Map<Integer, SceneNPC> npc; // <NpcId, NPC> public Map<Integer, SceneNPC> npc; // <NpcId, NPC>
public List<SceneRegion> regions; public Map<Integer, SceneRegion> regions;
public List<SceneSuite> suites; public List<SceneSuite> suites;
public List<SceneVar> variables; public List<SceneVar> variables;
...@@ -115,7 +115,10 @@ public class SceneGroup { ...@@ -115,7 +115,10 @@ public class SceneGroup {
triggers.values().forEach(t -> t.currentGroup = this); triggers.values().forEach(t -> t.currentGroup = this);
suites = ScriptLoader.getSerializer().toList(SceneSuite.class, bindings.get("suites")); suites = ScriptLoader.getSerializer().toList(SceneSuite.class, bindings.get("suites"));
regions = ScriptLoader.getSerializer().toList(SceneRegion.class, bindings.get("regions")); regions = ScriptLoader.getSerializer().toList(SceneRegion.class, bindings.get("regions")).stream()
.collect(Collectors.toMap(x -> x.config_id, y -> y));
regions.values().forEach(m -> m.group = this);
init_config = ScriptLoader.getSerializer().toObject(SceneInitConfig.class, bindings.get("init_config")); init_config = ScriptLoader.getSerializer().toObject(SceneInitConfig.class, bindings.get("init_config"));
// Garbages TODO fix properly later // Garbages TODO fix properly later
...@@ -157,6 +160,13 @@ public class SceneGroup { ...@@ -157,6 +160,13 @@ public class SceneGroup {
.map(triggers::get) .map(triggers::get)
.toList() .toList()
); );
suite.sceneRegions = new ArrayList<>(
suite.regions.stream()
.filter(regions::containsKey)
.map(regions::get)
.toList()
);
} }
} catch (ScriptException e) { } catch (ScriptException e) {
......
package emu.grasscutter.scripts.data; package emu.grasscutter.scripts.data;
import emu.grasscutter.game.entity.GameEntity;
import emu.grasscutter.scripts.constants.ScriptRegionShape; import emu.grasscutter.scripts.constants.ScriptRegionShape;
import emu.grasscutter.utils.Position; import emu.grasscutter.utils.Position;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import lombok.Data;
import lombok.Setter; import lombok.Setter;
import lombok.ToString;
@ToString
@Setter @Setter
public class SceneRegion { public class SceneRegion {
public int config_id; public int config_id;
public int shape; public int shape;
public Position pos; public Position pos;
// for CUBIC
public Position size; public Position size;
// for SPHERE
public int radius;
private boolean hasNewEntities; public transient SceneGroup group;
private final IntSet entities; // Ids of entities inside this region public boolean contains(Position position) {
public SceneRegion() {
this.entities = new IntOpenHashSet();
}
public IntSet getEntities() {
return entities;
}
public void addEntity(GameEntity entity) {
if (this.getEntities().contains(entity.getId())) {
return;
}
this.getEntities().add(entity.getId());
this.hasNewEntities = true;
}
public void removeEntity(GameEntity entity) {
this.getEntities().remove(entity.getId());
}
public boolean contains(Position p) {
switch (shape) { switch (shape) {
case ScriptRegionShape.CUBIC: case ScriptRegionShape.CUBIC:
return (Math.abs(pos.getX() - p.getX()) <= size.getX()) && return (Math.abs(pos.getX() - position.getX()) <= size.getX()) &&
(Math.abs(pos.getZ() - p.getZ()) <= size.getZ()); (Math.abs(pos.getY() - position.getY()) <= size.getY()) &&
(Math.abs(pos.getZ() - position.getZ()) <= size.getZ());
case ScriptRegionShape.SPHERE: case ScriptRegionShape.SPHERE:
return false; var x = Math.pow(pos.getX() - position.getX(), 2);
var y = Math.pow(pos.getY() - position.getY(), 2);
var z = Math.pow(pos.getZ() - position.getZ(), 2);
return x + y + z <= (radius ^ 2);
} }
return false; return false;
} }
public boolean hasNewEntities() {
return hasNewEntities;
}
public void resetNewEntities() {
hasNewEntities = false;
}
} }
...@@ -11,9 +11,12 @@ public class SceneSuite { ...@@ -11,9 +11,12 @@ public class SceneSuite {
public List<Integer> monsters; public List<Integer> monsters;
public List<Integer> gadgets; public List<Integer> gadgets;
public List<String> triggers; public List<String> triggers;
public List<Integer> regions;
public int rand_weight; public int rand_weight;
public transient List<SceneMonster> sceneMonsters; public transient List<SceneMonster> sceneMonsters;
public transient List<SceneGadget> sceneGadgets; public transient List<SceneGadget> sceneGadgets;
public transient List<SceneTrigger> sceneTriggers; public transient List<SceneTrigger> sceneTriggers;
public transient List<SceneRegion> sceneRegions;
} }
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment