Save current progress

This commit is contained in:
Zontreck 2024-02-13 21:58:34 -07:00
parent 669cd9e789
commit d4740d71e7
13 changed files with 631 additions and 31 deletions

View file

@ -68,7 +68,12 @@ repositories {
dependencies {
provided "dev.zontreck:EventsBus:${Bus_API}.${Bus_Patch}"
provided "dev.zontreck:LibAC:${LibAC_API}.${LibAC_Patch}"
provided "org.mariadb.jdbc:mariadb-java-client:${MariaDB_JDBC_Version}"
provided "org.slf4j:log4j-over-slf4j:2.0.7"
provided "org.slf4j:slf4j-simple:2.0.7"
}
def MAVEN_PASSWORD_PROPERTY = "AriasCreationsMavenPassword"
@ -117,7 +122,8 @@ task jarjar(type: Jar) {
manifest {
attributes (
'Main-Class': application.mainClass,
'Multi-Release': 'true'
'Multi-Release': 'true',
'Implementation-Version': version
)
}
archiveClassifier = "AIO"

View file

@ -1,9 +1,11 @@
apiVer=1.0
Bus_API=1.0
Bus_Patch=33
Bus_Patch=45
LibAC_API=1.4
LibAC_Patch=35
LibAC_Patch=46
MariaDB_JDBC_Version=3.3.2
org.gradle.daemon=false

View file

@ -11,9 +11,14 @@ import dev.zontreck.ariaslib.terminal.Banners;
import dev.zontreck.ariaslib.util.FileIO;
import dev.zontreck.ariaslib.util.Hashing;
import dev.zontreck.eventsbus.Bus;
import dev.zontreck.eventsbus.EventDispatcher;
import dev.zontreck.playsync.data.DataAccountant;
import dev.zontreck.playsync.data.DataFragment;
import dev.zontreck.playsync.data.Manifest;
import dev.zontreck.playsync.data.database.DatabaseConnection;
import dev.zontreck.playsync.data.database.Migrations;
import dev.zontreck.playsync.data.database.migrations.ChunksTable;
import dev.zontreck.playsync.data.database.migrations.ManifestsTable;
import dev.zontreck.playsync.events.EventHandlers;
import dev.zontreck.playsync.exceptions.UnsupportedAlgorithmException;
import dev.zontreck.playsync.exceptions.UnsupportedIDException;
@ -22,6 +27,8 @@ import dev.zontreck.playsync.games.nintendo.ID6;
import dev.zontreck.playsync.server.HTTPServer;
import java.io.*;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
@ -36,12 +43,26 @@ public class PlaySyncServer {
DEFAULT_ARGS.setArg(new IntegerArgument("port", 1588));
DEFAULT_ARGS.setArg(new StringArgument("dataDir", "data"));
DEFAULT_ARGS.setArg(new StringArgument("hash", "MD5"));
DEFAULT_ARGS.setArg(new IntegerArgument("db_port", 3306));
DEFAULT_ARGS.setArg(new StringArgument("db_host", "localhost"));
DEFAULT_ARGS.setArg(new StringArgument("db_name", "PlaySync"));
DEFAULT_ARGS.setArg(new StringArgument("db_user", "NSET\r"));
DEFAULT_ARGS.setArg(new StringArgument("db_pass", "NSET\r"));
}
Settings.PORT_NUMBER = (int) DEFAULT_ARGS.getArg("port").getValue();
Settings.DATA_DIRECTORY = (String)DEFAULT_ARGS.getArg("dataDir").getValue();
Settings.HASH_ALGORITHM = (String)DEFAULT_ARGS.getArg("hash").getValue();
public static void printUsage()
{
log("Usage: java -jar PlaySyncServer.jar --db_user=<value> --db_pass=<value> [options]");
log("");
log("--port=<value>\t\t\tDefault: 1588\n",
"--dataDir=<value>\t\tDefault: data\n",
"--hash=<value>\t\t\tDefault: MD5, (valid: MD5, SHA256)\n",
"--db_port=<value>\t\tDefault: 3306\n",
"--db_host=<value>\t\tDefault: localhost\n",
"--db_user=<value>\t\t(REQUIRED)\n",
"--db_name=<value>\t\tDefault: PlaySync\n",
"--db_pass=<value>\t\t(REQUIRED)\n");
}
private static void log(String... args)
@ -74,19 +95,26 @@ public class PlaySyncServer {
}
public static void main(String[] args) {
log(Banners.generateBanner("PlaySync Server"));
log(Banners.generateBanner("Harbinger CDN Server"));
log("Version: "+PlaySyncServer.class.getPackage().getImplementationVersion() + "\n");
Arguments active = ArgumentsParser.parseArguments(args, DEFAULT_ARGS);
IntegerArgument port = (IntegerArgument) active.getArg("port");
StringArgument dataDir = (StringArgument) active.getArg("dataDir");
StringArgument hashing = (StringArgument) active.getArg("hash");
log("Registering EventHandlers");
Bus.Register(EventHandlers.class, new EventHandlers());
log("EventHandlers have been registered");
IntegerArgument dbPort = (IntegerArgument) active.getArg("db_port");
StringArgument dbHost = (StringArgument) active.getArg("db_host");
StringArgument dbName = (StringArgument) active.getArg("db_name");
StringArgument dbUser = (StringArgument) active.getArg("db_user");
StringArgument dbPass = (StringArgument) active.getArg("db_pass");
log("Parsing command line arguments...");
if(active.hasArg("port"))
Settings.PORT_NUMBER = port.getValue();
@ -96,10 +124,72 @@ public class PlaySyncServer {
if(active.hasArg("hash"))
Settings.HASH_ALGORITHM = hashing.getValue();
if(active.hasArg("db_port"))
Settings.DB_PORT = dbPort.getValue();
if(active.hasArg("db_host"))
Settings.DB_HOST = dbHost.getValue();
if(active.hasArg("db_name"))
Settings.DB_NAME = dbName.getValue();
if(active.hasArg("db_user"))
{
if(dbUser.getValue().equalsIgnoreCase("NSET\r"))
{
printUsage();
log("Argument: --db_user is required");
System.exit(1);
}
Settings.DB_USERNAME = dbUser.getValue();
}
if(active.hasArg("db_pass"))
{
if(dbPass.getValue().equalsIgnoreCase("NSET\r"))
{
log("Argument: --db_pass is required if the password is not blank");
Settings.DB_PASSWORD = "";
}else
Settings.DB_PASSWORD = dbPass.getValue();
}
log("Finished parsing command line options");
log("Starting the EventDispatcher");
EventDispatcher.Reset();
EventDispatcher.Register(EventHandlers.class);
log("EventDispatcher has been started.");
log("Port number set to: ", String.valueOf(Settings.PORT_NUMBER));
log("Data will be loaded/stored in: ", Settings.DATA_DIRECTORY);
log("Hashing algorithm set to: ", Settings.HASH_ALGORITHM);
log("Connecting to database...");
Connection conn = null;
try {
conn = DatabaseConnection.getConnection();
} catch (SQLException e) {
e.printStackTrace();
log("\n\n");
log(Banners.generateBanner("Invalid Credentials or DB Settings"));
System.exit(1);
}
log("Connection established");
log("Performing database migrations...");
try {
Migrations.doMigration(conn);
} catch (SQLException e) {
e.printStackTrace();
log(Banners.generateBanner("Critical Failure when creating migrations table"));
System.exit(1);
}
File data = Settings.getDataFolder();
if(!data.exists())
{
@ -125,6 +215,7 @@ public class PlaySyncServer {
*/
mf.FileHash = Hashing.md5(fullFile);
mf.GamePlatform = Platform.Nintendo;
mf.GameName = "Animal Crossing";
DataAccountant.DataTotal = fullFile.length;
@ -145,7 +236,7 @@ public class PlaySyncServer {
{
try {
DataFragment frag = DataFragment.Create(dis);
if(frag.getSize()<1024)
if(frag.getSize()<512)
{
hasData=false;
}
@ -182,7 +273,11 @@ public class PlaySyncServer {
for(DataFragment frag : fragments)
{
frag.Save();
ChunksTable chunksTable = (ChunksTable)Migrations.Tables.get(ChunksTable.class);
chunksTable.setChunk(frag);
DataAccountant.ChunksWritten++;
DataAccountant.ChunksRemain--;
DataAccountant.updateCur(DataAccountant.ChunksWritten);

View file

@ -9,6 +9,14 @@ public class Settings
public static String DATA_DIRECTORY;
public static String HASH_ALGORITHM;
public static int DB_PORT;
public static String DB_HOST;
public static String DB_USERNAME;
public static String DB_PASSWORD;
public static String DB_NAME;
public static final boolean DEBUG_MODE = true;

View file

@ -2,6 +2,8 @@ package dev.zontreck.playsync.data;
import dev.zontreck.ariaslib.util.Hashing;
import dev.zontreck.playsync.Settings;
import dev.zontreck.playsync.data.database.Migrations;
import dev.zontreck.playsync.data.database.migrations.ChunksTable;
import dev.zontreck.playsync.exceptions.UnsupportedAlgorithmException;
import javax.xml.crypto.Data;
@ -13,7 +15,7 @@ import java.nio.file.Path;
*/
public class DataFragment
{
private byte[] data = new byte[1024];
private byte[] data = new byte[512];
/**
* Takes either 1024, or the remaining number of bytes, whichever is less
@ -22,7 +24,7 @@ public class DataFragment
*/
public static DataFragment Create(DataInputStream dis) throws IOException {
DataFragment frag = new DataFragment();
frag.data = dis.readNBytes(1024);
frag.data = dis.readNBytes(512);
return frag;
}
@ -30,32 +32,50 @@ public class DataFragment
/**
* Save the data fragment to its hash ID
*/
public void Save()
public byte[] Save()
{
Path chunks = Settings.getDataChunksFolder();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(baos);
try {
Path chunk = chunks.resolve(getHash() + ".bin");
if(!chunks.toFile().exists()) chunks.toFile().mkdirs();
//DataAccountant.DataWritten += data.length;
if(chunk.toFile().exists()) {
if(((ChunksTable)Migrations.Tables.get(ChunksTable.class)).hasChunk(getHash()))
{
DataAccountant.DataSkipped += data.length;
return;
}
DataOutputStream dos = new DataOutputStream(new FileOutputStream(chunk.toFile()));
dos.writeInt(getSize());
dos.write(data);
dos.close();
} catch (UnsupportedAlgorithmException e) {
throw new RuntimeException(e);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
return baos.toByteArray();
}
/**
* Read from a byte array
* @param array Array to load as a data fragment
* @return The fragment of data
*/
public static DataFragment Read(byte[] array)
{
ByteArrayInputStream bais = new ByteArrayInputStream(array);
DataInputStream dis = new DataInputStream(bais);
DataFragment fragment = new DataFragment();
try {
int count = dis.readInt();
fragment.data = dis.readNBytes(count);
} catch (IOException e) {
throw new RuntimeException(e);
}
return fragment;
}
/**

View file

@ -19,6 +19,7 @@ public class Manifest
public List<String> Fragments = new ArrayList<>();
public String FileHash = "";
public ID6 GameID;
public String GameName;
public Platform GamePlatform = Platform.Unknown;
private static final ReentrantReadWriteLock LOCK = new ReentrantReadWriteLock();
@ -56,6 +57,7 @@ public class Manifest
DataOutputStream dos = new DataOutputStream(new FileOutputStream(manifest.toFile()));
dos.write(GameID.asBytes());
dos.writeByte(GamePlatform.ordinal());
dos.writeUTF(GameName);
dos.writeInt(Fragments.size());
@ -91,6 +93,7 @@ public class Manifest
ret.GameID = ID6.fromBytes(dis.readNBytes(6));
ret.GamePlatform = Platform.fromByte(dis.readByte());
ret.GameName = dis.readUTF();
ret.FileHash = hash;
int count = dis.readInt();
for(int i=0;i<count;i++)

View file

@ -0,0 +1,36 @@
package dev.zontreck.playsync.data.database;
import dev.zontreck.playsync.Settings;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DatabaseConnection {
private static Connection connection = null;
public static Connection getConnection() throws SQLException {
if (connection == null || connection.isClosed()) {
try {
// Load MariaDB JDBC driver
Class.forName("org.mariadb.jdbc.Driver");
// Establish connection
connection = DriverManager.getConnection(
"jdbc:mariadb://" + Settings.DB_HOST + ":" + Settings.DB_PORT + "/" + Settings.DB_NAME,
Settings.DB_USERNAME, Settings.DB_PASSWORD
);
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
throw new SQLException("Failed to connect to database.");
}
}
return connection;
}
public static void closeConnection() throws SQLException {
if (connection != null && !connection.isClosed()) {
connection.close();
}
}
}

View file

@ -0,0 +1,176 @@
package dev.zontreck.playsync.data.database;
import dev.zontreck.eventsbus.Bus;
import dev.zontreck.eventsbus.EventDispatcher;
import dev.zontreck.playsync.server.events.MigrationEvent;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Migrations {
private static final String MIGRATIONS_TABLE_NAME = "migrations";
public static Map<Class<?>,Migration> Tables = new HashMap<>();
public static void createMigrationsTable(Connection connection) throws SQLException {
String createTableQuery = "CREATE TABLE IF NOT EXISTS " + MIGRATIONS_TABLE_NAME + " ("
+ "table_name VARCHAR(255) PRIMARY KEY,"
+ "version INT NOT NULL"
+ ")";
try (PreparedStatement statement = connection.prepareStatement(createTableQuery)) {
statement.executeUpdate();
}
}
public static int getTableVersion(Connection connection, String tableName) throws SQLException {
String selectQuery = "SELECT version FROM " + MIGRATIONS_TABLE_NAME + " WHERE table_name = ?";
try (PreparedStatement statement = connection.prepareStatement(selectQuery)) {
statement.setString(1, tableName);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
return resultSet.getInt("version");
}
}
}
// Default version if not found
return 0;
}
/**
* Set the table version in the migrations table
* @param connection The DB connection to use
* @param tableName The table name to update
* @param version The new version to store
* @throws SQLException
*/
public static void setTableVersion(Connection connection, String tableName, int version) throws SQLException {
String updateQuery = "INSERT INTO " + MIGRATIONS_TABLE_NAME + " (table_name, version) VALUES (?, ?)"
+ " ON DUPLICATE KEY UPDATE version = ?";
try (PreparedStatement statement = connection.prepareStatement(updateQuery)) {
statement.setString(1, tableName);
statement.setInt(2, version);
statement.setInt(3, version);
statement.executeUpdate();
}
}
public static void doMigration(Connection connection) throws SQLException {
int ver = 0;
try {
ver = getTableVersion(connection, MIGRATIONS_TABLE_NAME);
}catch(SQLException e)
{
createMigrationsTable(connection);
setTableVersion(connection, MIGRATIONS_TABLE_NAME, 1);
ver = getTableVersion(connection, MIGRATIONS_TABLE_NAME);
}
if(ver == 0)
{
createMigrationsTable(connection);
setTableVersion(connection, MIGRATIONS_TABLE_NAME, 1);
}
switch(ver)
{
default:
{
break; // nothing to do here yet
}
}
EventDispatcher.Post(new MigrationEvent());
}
/**
* Abstract class defining the functions that will need to be used by any table implementing migrations.
*/
public static abstract class Migration {
/**
* Gets the name of the table associated with this migration.
*
* @return The name of the table.
*/
public abstract String getTableName();
/**
* Creates the table associated with this migration if it doesn't exist.
*
* @param connection The database connection.
* @throws SQLException if a database access error occurs or this method is called on a closed connection.
*/
public abstract void createTable(Connection connection) throws SQLException;
/**
* Gets the current version of the table from the migrations table.
*
* @return The current version of the table.
* @throws SQLException if a database access error occurs or this method is called on a closed connection.
*/
public int getVersion() throws SQLException {
return Migrations.getTableVersion(DatabaseConnection.getConnection(), getTableName());
}
/**
* Sets the version of the table in the migrations table.
*
* @param version The version to set.
* @throws SQLException if a database access error occurs or this method is called on a closed connection.
*/
public void setVersion(int version) throws SQLException {
Migrations.setTableVersion(DatabaseConnection.getConnection(), getTableName(), version);
}
/**
* This is used for the internal doMigrations function. It should return the current highest migration version for the table
* @return Current highest version
*/
public abstract int getCurrentTableVersion();
/**
* Used internally.
* <br/><br/>
* This function executes migrations to bring the table to the current version.
* @throws SQLException
*/
public void doMigration() throws SQLException
{
int ver = getVersion();
Connection conn = DatabaseConnection.getConnection();
if(ver==0) createTable(conn);
while(ver < getCurrentTableVersion())
{
migrate(ver+1);
System.out.println("Migrate Table [" + getTableName() +"] from version " + ver + " to " + (ver+1));
ver = getVersion();
}
}
/**
* Perform a migration specific to the table itself
* @param version
* @throws SQLException
*/
public abstract void migrate(int version) throws SQLException;
}
}

View file

@ -0,0 +1,146 @@
package dev.zontreck.playsync.data.database.migrations;
import dev.zontreck.playsync.data.DataFragment;
import dev.zontreck.playsync.data.database.DatabaseConnection;
import dev.zontreck.playsync.data.database.Migrations;
import dev.zontreck.playsync.exceptions.UnsupportedAlgorithmException;
import javax.sql.RowSet;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class ChunksTable extends Migrations.Migration {
@Override
public String getTableName() {
return "chunks";
}
@Override
public void createTable(Connection connection) throws SQLException {
String sql = "CREATE TABLE `chunks` (" +
" `Hash` varchar(255) NOT NULL," +
" `Data` blob NOT NULL," +
" PRIMARY KEY (`Hash`)," +
" UNIQUE KEY `Hash` (`Hash`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci";
try (PreparedStatement pstat = connection.prepareStatement(sql))
{
pstat.executeUpdate();
}
setVersion(1);
}
@Override
public int getCurrentTableVersion() {
return 1;
}
@Override
public void migrate(int version) throws SQLException {
Connection conn = DatabaseConnection.getConnection();
switch(version)
{
case 1:
{
break;
}
}
}
/**
* Query the database to check if the Chunk exists or not
* @param hash The chunk's hash value
* @return True if the chunk is present in the database
*/
public boolean hasChunk(String hash)
{
String sql = "SELECT * FROM `?`" +
" WHERE Hash='?';";
try(PreparedStatement pstat = DatabaseConnection.getConnection().prepareStatement(sql))
{
pstat.setString(1, getTableName());
pstat.setString(2, hash);
ResultSet result = pstat.executeQuery();
if(result.getRow() == 0 && !result.next())
{
return false;
} else {
return true;
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
/**
* Get the chunk if it exists.
* <br/>
* If the chunk does not exist, returns null, otherwise, never null.
* @param hash The hash to check for
* @see ChunksTable#hasChunk(String)
* @return The deserialied DataFragment
*/
public DataFragment getChunk(String hash)
{
if(!hasChunk(hash)) return null;
String sql = "SELECT Data from `?`" +
" WHERE Hash='?';";
try (PreparedStatement pstat = DatabaseConnection.getConnection().prepareStatement(sql))
{
pstat.setString(1, getTableName());
pstat.setString(2, hash);
ResultSet result = pstat.executeQuery();
if(result.getRow()==0) result.next();
byte[] blob = result.getBytes("Data");
return DataFragment.Read(blob);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
/**
* Insert the data
* @param fragment The fragment to insert
*/
public void setChunk(DataFragment fragment)
{
try {
String hash = fragment.getHash();
byte[] array = fragment.Save();
String sql = "INSERT INTO `?` (Hash, Data) VALUES (" +
"'?', ?" +
");";
try(PreparedStatement pstat = DatabaseConnection.getConnection().prepareStatement(sql))
{
pstat.setString(1, getTableName());
pstat.setString(2, hash);
pstat.setBytes(3, array);
pstat.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
} catch (UnsupportedAlgorithmException e) {
throw new RuntimeException(e);
}
}
}

View file

@ -0,0 +1,73 @@
package dev.zontreck.playsync.data.database.migrations;
import dev.zontreck.playsync.data.database.DatabaseConnection;
import dev.zontreck.playsync.data.database.Migrations;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public class ManifestsTable extends Migrations.Migration
{
@Override
public String getTableName() {
return "manifests";
}
@Override
public void createTable(Connection connection) throws SQLException {
String sql = "CREATE TABLE `" + getTableName() + "` (" +
" `Name` varchar(255) NOT NULL," +
" `Data` blob NOT NULL," +
" PRIMARY KEY (`Name`)," +
" UNIQUE KEY `Name` (`Name`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;";
try (PreparedStatement pstat = connection.prepareStatement(sql))
{
pstat.executeUpdate();
}
setVersion(1);
}
@Override
public int getCurrentTableVersion() {
return 3;
}
@Override
public void migrate(int version) throws SQLException {
Connection conn = DatabaseConnection.getConnection();
switch(version)
{
case 1:
{
String sql = "ALTER TABLE `" + getTableName() + "` " +
"CHANGE `Name` `Hash` VARCHAR(255) " +
"CHARACTER SET utf8mb4 " +
"COLLATE utf8mb4_general_ci NOT NULL;";
try(PreparedStatement pstat = conn.prepareStatement(sql))
{
pstat.executeUpdate();
}
setVersion(2);
break;
}
case 2:
{
String sql = "ALTER TABLE `manifests` ADD `GameTitle` " +
"VARCHAR(255) NOT NULL AFTER `Hash`; ";
try(PreparedStatement pstat = conn.prepareStatement(sql))
{
pstat.executeUpdate();
}
setVersion(3);
break;
}
}
}
}

View file

@ -1,5 +1,34 @@
package dev.zontreck.playsync.events;
import dev.zontreck.ariaslib.util.Maps;
import dev.zontreck.eventsbus.annotations.EventSubscriber;
import dev.zontreck.eventsbus.annotations.SingleshotEvent;
import dev.zontreck.eventsbus.annotations.Subscribe;
import dev.zontreck.playsync.data.database.Migrations;
import dev.zontreck.playsync.data.database.migrations.ChunksTable;
import dev.zontreck.playsync.data.database.migrations.ManifestsTable;
import dev.zontreck.playsync.server.events.MigrationEvent;
import java.sql.SQLException;
@EventSubscriber
public class EventHandlers
{
@Subscribe(allowCancelled = false)
@SingleshotEvent
public static void onMigrations(MigrationEvent event)
{
System.out.println("Performing table migrations...");
ManifestsTable manifests = new ManifestsTable();
ChunksTable chunks = new ChunksTable();
try {
manifests.doMigration();
chunks.doMigration();
} catch (SQLException e) {
throw new RuntimeException(e);
}
Migrations.Tables = Maps.of(new Maps.Entry<>(ManifestsTable.class, manifests), new Maps.Entry<>(ChunksTable.class, chunks));
System.out.println("Table migrations completed...");
}
}

View file

@ -1,8 +1,7 @@
package dev.zontreck.playsync.server.events;
import dev.zontreck.ariaslib.util.Lists;
import dev.zontreck.eventsbus.Cancellable;
import dev.zontreck.eventsbus.Event;
import dev.zontreck.eventsbus.annotations.Cancellable;
import dev.zontreck.playsync.exceptions.HTTPResponseLockedException;
import dev.zontreck.playsync.server.HTTPResponseData;

View file

@ -0,0 +1,7 @@
package dev.zontreck.playsync.server.events;
import dev.zontreck.eventsbus.Event;
public class MigrationEvent extends Event
{
}