diff --git a/MekHQ/.classpath b/MekHQ/.classpath index b616e82356..9805dad7f2 100644 --- a/MekHQ/.classpath +++ b/MekHQ/.classpath @@ -16,6 +16,19 @@ + + + + + + + + + + + + + diff --git a/MekHQ/build.gradle b/MekHQ/build.gradle index 2eb416994a..6536a7aa52 100644 --- a/MekHQ/build.gradle +++ b/MekHQ/build.gradle @@ -4,12 +4,17 @@ plugins { id 'application' id 'maven-publish' id 'edu.sc.seis.launch4j' version '2.4.4' + id 'com.google.protobuf' version '0.8.8' } sourceSets { main { java { - srcDirs = ['src'] + srcDirs = [ + 'src', + 'build/generated/source/proto/main/grpc', + 'build/generated/source/proto/main/java' + ] } resources { srcDirs = ['resources'] @@ -61,12 +66,33 @@ dependencies { runtimeOnly 'org.glassfish.jaxb:jaxb-core:2.3.0' runtimeOnly 'com.sun.activation:javax.activation:1.2.0' + // gRPC for Distributed MekHQ + implementation 'io.grpc:grpc-netty-shaded:1.27.1' + implementation 'io.grpc:grpc-protobuf:1.27.1' + implementation 'io.grpc:grpc-stub:1.27.1' + jarbundler 'com.ultramixer.jarbundler:jarbundler-core:3.3.0' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:2.20.1' } +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.11.0" + } + plugins { + grpc { + artifact = 'io.grpc:protoc-gen-grpc-java:1.27.1' + } + } + generateProtoTasks { + all()*.plugins { + grpc {} + } + } +} + mainClassName = 'mekhq.MekHQ' ext { diff --git a/MekHQ/resources/mekhq/resources/CampaignGUI.properties b/MekHQ/resources/mekhq/resources/CampaignGUI.properties index 0746d1ea73..b3072db007 100644 --- a/MekHQ/resources/mekhq/resources/CampaignGUI.properties +++ b/MekHQ/resources/mekhq/resources/CampaignGUI.properties @@ -274,3 +274,6 @@ tabOverview.toolTipText=A general overview of the unit, includes multiple breakd bottomRating.DragoonsRating=Dragoons Rating: %s bottomRating.CampaignOpsRating=Campaign Ops Rating: %s + +# Online Tab +panOnline.TabConstraints.tabTitle=Online Session diff --git a/MekHQ/src/main/proto/Service.proto b/MekHQ/src/main/proto/Service.proto new file mode 100644 index 0000000000..273be9ef03 --- /dev/null +++ b/MekHQ/src/main/proto/Service.proto @@ -0,0 +1,478 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "mekhq.online"; +option java_outer_classname = "DistributedMekHQ"; + +import "google/protobuf/any.proto"; +import "google/protobuf/timestamp.proto"; + +package mekhqonline; + +// The MekHQHost Service handles distributed MekHQ play +// between a Host Campaign and one or more Client Campaigns. +service MekHQHost { + // + // Initiates a connection to the Host Campaign. + // + rpc Connect(ConnectionRequest) returns (ConnectionResponse) {} + + // + // Advises the Host Campaign that a Client Campaign + // is no longer participating in the session. + // + rpc Disconnect(DisconnectionRequest) returns (DisconnectionResponse) {} + + // + // Initiates a Message Bus between the Client Campaign + // and Host Campaign. + // + rpc MessageBus(stream ClientMessage) returns (stream ServerMessage) {} +} + +// Represents a request to initiate a connection to a host +// campaign. +message ConnectionRequest { + // The version of MekHQ on the Client machine. + string version = 1; + + // A CampaignDetails message with details of the Client Campaign. + CampaignDetails client = 2; + + // An optional password to use during connection attempts. + oneof password_option { + // The password required to initiate the connection. + string password = 3; + } +} + +// Represents a response to a successful connection attempt +// containing details of the Host Campaign. +message ConnectionResponse { + // The version of MekHQ on the Host machine. + string version = 1; + + // A CampaignDetails message with details of the Host Campaign. + CampaignDetails host = 2; + + // Zero or more CampaignDetails messages representing the + // Client Campaigns involved in the session. + // + // These campaigns may or may not be active. + repeated CampaignDetails campaigns = 3; +} + +// Represents a request to disconnect from the Host Campaign. +message DisconnectionRequest { + // The unique identifier of the Client Campaign. + string id = 1; +} + +// Represents a response to a successful disconnection attempt. +message DisconnectionResponse { + // The unique identifier of the Host Campaign. + string id = 1; +} + +// Represents the details of a specific Campaign involved +// in the session. +message CampaignDetails { + // The unique identifier of the Campaign. + string id = 1; + + // The name of the Campaign. + string name = 2; + + // The date of the Campaign (ISO8601) + string date = 3; + + // The team number of the Campaign. + // NOTE: The Host Campaign is always team 1. + int32 team = 4; + + // The location of the Campaign. + string location = 5; + + // A value indicating whether or not the Campaign + // is operating under GM Mode. + bool isGMMode = 6; + + // A value indicating whether or not the Campaign + // is actively playing in this session. + // + // Client Campaigns should not set this value + // when sending a CampaignDetails message. + // + // Host Campaigns should not read this value in + // messages from Client Campaigns. + bool is_active = 7; +} + +// Represents a message from a Client Campaign +// to the Host Campaign over the message bus. +message ClientMessage { + // The time when the message was generated. + google.protobuf.Timestamp timestamp = 1; + + // The unique identifier of the Client Campaign. + string id = 2; + + // An optional map of metadata associated + // with this message. + map metadata = 3; + + // The eveloped message being sent to the Host Campaign. + google.protobuf.Any message = 4; +} + +// Represents a message from the Host Campaign +// to a Client Campaign over the message bus. +message ServerMessage { + // The time when the message was generated. + google.protobuf.Timestamp timestamp = 1; + + // The unique identifier of the Host Campaign. + string id = 2; + + // An optional map of metadata associated + // with this message. + map metadata = 3; + + // The enveloped message being sent to a Client Campaign. + google.protobuf.Any message = 4; +} + +// Represents a PING being sent to keep-alive +// the connection between a Host and Client Campaign. +// +// This message can be sent by either the Host or +// a Client Campaign. +// +// Any Campaign which receives a Ping message should +// respond as soon as possible with a Pong message. +message Ping { + // A CampaignDetails message which contains details + // from the sending campaign. + CampaignDetails campaign = 1; +} + +// Represents a PONG being sent in response to +// a PING message. +// +// This message can be sent by either the Host or +// a Client Campaign, but only in response to a +// previous PING message. +// +// Only the Host Campaign sends along data about +// the other campaigns in the session, and should +// ignore any data in the `campaigns` field. +message Pong { + // A CampaignDetails message which contains details + // from the sending campaign. + CampaignDetails campaign = 1; + + // Zero or more CampaignDetails messages representing the + // Client Campaigns involved in the session. + // + // These campaigns may or may not be active. + repeated CampaignDetails campaigns = 2; +} + +// Represents a message sent when a Campaign changes +// dates. +// +// This message may be sent by any Campaign, and will +// be forwarded by the Host Campaign to everyone in +// the current session. +message CampaignDateChanged { + // The unique identifier of the Campaign. + string id = 1; + + // The new date of the Campaign (ISO8601). + string date = 2; +} + +// Represents a message sent when a Campaign changes +// its GM Mode. +// +// This message may be sent by any Campaign, and will +// be forwarded by the Host Campaign to everyone in +// the current session. +message GMModeChanged { + // The unique identifier of the Campaign. + string id = 1; + + // A value indicating whether or not the Campaign + // is operating under GM Mode. + bool value = 2; +} + +// Represents a message sent when Team Assignments change. +// +// This message is only sent by the Host Campaign to the +// connected Client Campaigns. +message TeamAssignmentChanged { + // Zero or more TeamAssignment messages detailing + // which team each Campaign is assigned to. + // + // If no TeamAssignment messages are sent, then every + // Campaign is on its own team. + repeated TeamAssignment teams = 1; +} + +// Represents a message detailing a specific Team Assignment. +message TeamAssignment { + // The unique identifier of the Campaign. + string id = 1; + + // The team number of the Campaign. + // + // NOTE: The Host Campaign is always on team 1. + int32 team = 2; +} + +// Represents a message sent when a Campaign changes locations, +// either due to a KF Jump or due to GM movement. +message LocationChanged { + // The unique identifier of the Campaign. + string id = 1; + + // The new location of the Campaign. + string location = 2; + + // A value indicating whether or not the movement + // was due to a GM action. + bool is_gm_movement = 3; +} + +// Represents a message sent when a Campaign updates its daily +// log. Zero or more of these messages may be sent for the same +// date. +message LogUpdated { + // The unique identifier of the Campaign. + string id = 1; + + // The date that this log entry corresponds to. + string date = 2; + + // Zero or more LogEntry messages containing the + // log entries for that date. + repeated LogEntry entries = 3; +} + +// Represents an entry in the daily log. +message LogEntry { + // The daily log entry. + string entry = 1; +} + +// Represents a message sent when the TOE for a Campaign +// is updated. +// +// This message may be sent by any Campaign, and will +// be forwarded by the Host Campaign to everyone in +// the current session. +// +// This message may be restricted to campaigns on the +// same team. +message TOEUpdated { + // The unique identifier of the Campaign. + string id = 1; + + // The top level Force in the Campaign. + Force force = 2; +} + +// Represents a Force within a Campaign's TOE. +message Force { + // The unique identifier of the Force. + int32 id = 1; + + // The name of the force. + string name = 2; + + // Zero or more sub-forces assigned + // to this Force. + repeated Force sub_forces = 3; + + // Zero or more units attached directly + // to this Force. + repeated ForceUnit units = 4; +} + +// Represents a unit within a Force in a Campaign's +// TOE. +message ForceUnit { + // The unique identifier of the Unit. + string id = 1; + + // The name of the Unit. + string name = 2; + + // The name of the Unit's commander. + string commander = 3; +} + +// Represents a Scenario within a Campaign. +message ScenarioIdentifier { + // The unique identifier of the Campaign + // who owns the scenario + string source_id = 1; + + // The unique identifier of the Contract + // for the source campaign + int32 contract_id = 2; + + // The unique identifier of the Scenario + // within the Contract + int32 scenario_id = 3; +} + +// Represents the deployment details of a Force +// within a Scenario. +message ForceDeployment { + // The number of rounds the force will be delayed + // upon deployment. + int32 deployment_delay = 1; + + // The edge of the board the force will be deployed + // to. + int32 board_start = 2; +} + +// Represents a Force assigned to a Scenario +message ScenarioForce { + // The unique identifier of the Campaign + // which owns this force, or null if + // the owner is a bot. + string owner_id = 1; + + // The name of the force's owner. + string owner_name = 2; + + // The team number of the force's owner. + int32 team = 3; + + // An optional Force assigned to the Scenario. + Force force = 4; + + // Zero-or-more units assigned to the Scenario. + repeated ForceUnit units = 5; + + // Details of how the force will be deployed + // in the Scenario. + // + // This is sent by the Host Campaign only. + ForceDeployment deployment = 6; +} + +// Represents an objective within a Scenario. +message ScenarioObjective { + // A description of the objective. + string description = 1; + + // Zero or more forces affected by the objective. + repeated string affected_forces = 2; + + // Zero or more units affected by the objective. + repeated string affected_units = 3; +} + +// Represents the details of a Scenario. +message ScenarioDetails { + // The unique identifier the Scenario. + ScenarioIdentifier scenario = 1; + + // The date of the scenario (ISO 8601). + string date = 2; + + // The name of the Scenario. + string name = 3; + + // A description of the Scenario. + string description = 4; + + // Zero or more objectives for the Scenario. + repeated ScenarioObjective objectives = 5; + + // The forces assigned to the Scenario + repeated ScenarioForce forces = 6; +} + +// Represents an invitation to join a Scenario. +message ScenarioInvitation { + // The details of the Scenario. + ScenarioDetails scenario = 1; + + // The unique identifier of the Campaign + // who is receiving the invitation + string target_id = 2; +} + +// Represents a message sent when the source or +// target campaign would no longer like to participate +// in a scenario together. +message ScenarioInvitationCancelled { + // The unique identifier the Scenario + ScenarioIdentifier scenario_id = 1; + + // The unique identifier of the Campaign + // who recieved the invitation + string target_id = 2; +} + +// Represents a message sent when the target +// campaign accepts a request to participate +// in a scenario. +message ScenarioInvitationAcceptance { + // The unique identifier the Scenario + ScenarioIdentifier scenario_id = 1; + + // The unique identifier of the Campaign + // who recieved the invitation + string target_id = 2; +} + +// Represents a message sent when the details +// of a scenario are updated. +message ScenarioDetailsUpdated { + // The details of the Scenario. + ScenarioDetails scenario = 1; + + // The unique identifier of the Campaign + // who should receive the update. + string target_id = 2; +} + +// Represents a message sent when the target +// campaign updates their units deployed to +// the scenario. +message ScenarioUnitsUpdated { + // The unique identifier the Scenario + ScenarioIdentifier scenario_id = 1; + + // The unique identifier of the Campaign + // who recieved the invitation + string target_id = 2; + + // Zero or more forces attached to the Scenario. + repeated ScenarioForce attached_forces = 3; +} diff --git a/MekHQ/src/mekhq/MekHQ.java b/MekHQ/src/mekhq/MekHQ.java index 67dbc37cc5..0646d3dc30 100644 --- a/MekHQ/src/mekhq/MekHQ.java +++ b/MekHQ/src/mekhq/MekHQ.java @@ -34,8 +34,12 @@ import javax.swing.*; import javax.swing.text.DefaultEditorKit; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; + import megamek.MegaMek; import megamek.client.Client; +import megamek.common.annotations.Nullable; import megamek.common.event.EventBus; import megamek.common.event.GameBoardChangeEvent; import megamek.common.event.GameBoardNewEvent; @@ -80,6 +84,8 @@ import mekhq.gui.dialog.RetirementDefectionDialog; import mekhq.gui.preferences.StringPreference; import mekhq.gui.utilities.ObservableString; +import mekhq.online.MekHQClient; +import mekhq.online.MekHQServer; import mekhq.preferences.MekHqPreferences; import mekhq.preferences.PreferencesNode; import mekhq.service.AutosaveService; @@ -130,6 +136,19 @@ public class MekHQ implements GameListener { private final IAutosaveService autosaveService; + private final ResourceBundle resourceBundle = ResourceBundle.getBundle("mekhq.resources.MekHQ"); + + /** A value indicating whether or not MekHQ is hosting an online session. */ + private boolean isHost; + /** The port to use when hosting the online session. */ + private int hostPort = 3000; + /** The address of the MekHQ instance to connect to (i.e. hostname:port) */ + private String remoteHost; + + private MekHQServer onlineServer; + private MekHQClient onlineClient; + private ManagedChannel channel; + /** * Converts the MekHQ {@link #VERBOSITY_LEVEL} to {@link LogLevel}. * @@ -285,12 +304,31 @@ private MekHQ() { /** * At startup create and show the main frame of the application. */ - protected void startup() { + protected void startup(String[] args) { UIManager.installLookAndFeel("Flat Light", "com.formdev.flatlaf.FlatLightLaf"); UIManager.installLookAndFeel("Flat IntelliJ", "com.formdev.flatlaf.FlatIntelliJLaf"); UIManager.installLookAndFeel("Flat Dark", "com.formdev.flatlaf.FlatDarkLaf"); UIManager.installLookAndFeel("Flat Darcula", "com.formdev.flatlaf.FlatDarculaLaf"); + for (String arg : args) { + if (arg.startsWith("--host")) { + setIsHosting(true); + int equals = arg.indexOf('='); + if (equals > 0) { + setHostPort(Integer.parseInt(arg.substring(equals + 1))); + } + } else if (arg.startsWith("--connect")) { + int equals = arg.indexOf('='); + if (equals < 0) { + // for testing we're defaulting to localhost:3000 + // this will go away outside of prototyping + setHostLocation("localhost:3000"); + } else { + setHostLocation(arg.substring(equals + 1)); + } + } + } + showInfo(); //Setup user preferences @@ -303,7 +341,35 @@ protected void startup() { sud.setVisible(true); } - private void setUserPreferences() { + public String getVersion() { + return resourceBundle.getString("Application.version"); + } + + private void setHostLocation(@Nullable String host) { + remoteHost = host; + } + + public boolean isRemote() { + return remoteHost != null; + } + + private void setIsHosting(boolean b) { + isHost = b; + } + + public boolean isHosting() { + return isHost; + } + + public void setHostPort(int port) { + hostPort = port; + } + + public int getHostPort() { + return hostPort; + } + + private void setUserPreferences() { PreferencesNode preferences = getPreferences().forClass(MekHQ.class); selectedTheme = new ObservableString("selectedTheme", UIManager.getLookAndFeel().getClass().getName()); @@ -354,10 +420,55 @@ public void exit() { } public void showNewView() { + + // Start up the host server or connect to a remote host + // if indicated. + connectDistributedFeatures(); + campaigngui = new CampaignGUI(this); campaigngui.showOverviewTab(getCampaign().isOverviewLoadingValue()); } + private void connectDistributedFeatures() { + + // Close any existing connections. + disconnectDistributedFeatures(); + + try { + if (isHosting()) { + onlineServer = new MekHQServer(hostPort, campaignController); + + onlineServer.start(); + } else if (isRemote()) { + channel = ManagedChannelBuilder.forTarget(remoteHost).usePlaintext().build(); + onlineClient = new MekHQClient(channel, campaignController); + + onlineClient.connect(); + } + } catch (IOException ex) { + MekHQ.getLogger().error(MekHQ.class, "showNewView()", "Could not connect to server.", ex); + + disconnectDistributedFeatures(); + } + } + + private void disconnectDistributedFeatures() { + try { + if (onlineServer != null) { + onlineServer.stop(); + } else if (channel != null) { + try { + onlineClient.disconnect(); + } catch (Exception ex) { + MekHQ.getLogger().error(MekHQ.class, "disconnectDistributedFeatures()", "Could not disconnect from server.", ex); + } + channel.shutdownNow(); + } + } catch (Exception ex) { + MekHQ.getLogger().error(MekHQ.class, "disconnectDistributedFeatures()", "Could not shut down existing online features.", ex); + } + } + /** * Main method launching the application. */ @@ -371,7 +482,7 @@ public static void main(String[] args) { // redirect output to log file redirectOutput(logFileName); // Deprecated call required for MegaMek usage - SwingUtilities.invokeLater(() -> MekHQ.getInstance().startup()); + SwingUtilities.invokeLater(() -> MekHQ.getInstance().startup(args)); } private void showInfo() { diff --git a/MekHQ/src/mekhq/campaign/CampaignController.java b/MekHQ/src/mekhq/campaign/CampaignController.java index 3946f97b05..422b445b7c 100644 --- a/MekHQ/src/mekhq/campaign/CampaignController.java +++ b/MekHQ/src/mekhq/campaign/CampaignController.java @@ -18,15 +18,34 @@ */ package mekhq.campaign; +import java.util.Collection; +import java.util.Collections; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.joda.time.DateTime; + +import mekhq.MekHQ; +import mekhq.campaign.universe.PlanetarySystem; +import mekhq.campaign.universe.Systems; +import mekhq.online.events.WaitingToAdvanceDayEvent; +import mekhq.online.forces.RemoteForce; +import mekhq.online.forces.RemoteTOE; /** * Manages the timeline of a {@link Campaign}. */ public class CampaignController { private final Campaign localCampaign; + private final ConcurrentHashMap remoteCampaigns = new ConcurrentHashMap<>(); + private final ConcurrentHashMap remoteTOEs = new ConcurrentHashMap<>(); + private boolean isHost; private UUID host; + private DateTime hostDate; + private String hostName; + private PlanetarySystem hostLocation; + private boolean hostIsGM; /** * Creates a new {@code CampaignController} for @@ -74,16 +93,105 @@ public boolean isHost() { return isHost; } + public void setHostDate(DateTime date) { + hostDate = date; + } + + public DateTime getHostDate() { + return hostDate; + } + + public void setHostName(String name) { + hostName = name; + } + + public String getHostName() { + return hostName; + } + + public void setHostLocation(String planetarySystemId) { + hostLocation = Systems.getInstance().getSystemById(planetarySystemId); + } + + public PlanetarySystem getHostLocation() { + return hostLocation; + } + + public void setHostIsGMMode(boolean isGMMode) { + hostIsGM = isGMMode; + } + + public boolean getHostIsGMMode() { + return hostIsGM; + } + + public void addActiveRemoteCampaign(UUID id, String name, DateTime date, String locationId, boolean isGMMode) { + addRemoteCampaign(id, name, date, locationId, isGMMode, /*isActive:*/ true); + } + + public void addRemoteCampaign(UUID id, String name, DateTime date, String locationId, boolean isGMMode, boolean isActive) { + PlanetarySystem planetarySystem = Systems.getInstance().getSystemById(locationId); + remoteCampaigns.put(id, new RemoteCampaign(id, name, date, planetarySystem, isGMMode, isActive)); + } + + public Collection getRemoteCampaigns() { + return Collections.unmodifiableCollection(remoteCampaigns.values()); + } + + public Collection getRemoteCampaignIds() { + return Collections.unmodifiableSet(remoteCampaigns.keySet()); + } + + public void setRemoteCampaignDate(UUID campaignId, DateTime campaignDate) { + // We only update this if the remote campaign is actually present + // otherwise the next PING-PONG will catch us up. + remoteCampaigns.computeIfPresent(campaignId, (key, remoteCampaign) -> remoteCampaign.withDate(campaignDate)); + } + + public void setRemoteCampaignLocation(UUID campaignId, String locationId) { + PlanetarySystem system = Systems.getInstance().getSystemById(locationId); + + // We only update this if the remote campaign is actually present + // otherwise the next PING-PONG will catch us up. + remoteCampaigns.computeIfPresent(campaignId, (key, remoteCampaign) -> remoteCampaign.withLocation(system)); + } + + public void setRemoteCampaignGMMode(UUID campaignId, boolean isGMMode) { + // We only update this if the remote campaign is actually present + // otherwise the next PING-PONG will catch us up. + remoteCampaigns.computeIfPresent(campaignId, (key, remoteCampaign) -> remoteCampaign.withGMMode(isGMMode)); + } + + public void removeActiveCampaign(UUID id) { + remoteCampaigns.computeIfPresent(id, (key, remoteCampaign) -> remoteCampaign.withActive(false)); + } + + public int getActiveCampaignCount() { + return remoteCampaigns.reduceValuesToInt(Integer.MAX_VALUE, rc -> rc.isActive() ? 1 : 0, 0, (x, acc) -> x + acc); + } + /** * Advances the local {@link Campaign} to the next day. */ - public void advanceDay() { + public boolean advanceDay() { if (isHost) { - if (getLocalCampaign().newDay()) { - // TODO: notifyDayChangedEvent(); - } + return getLocalCampaign().newDay(); } else { - // TODO: requestNewDay(); + if (getLocalCampaign().getDateTime().isBefore(getHostDate())) { + return getLocalCampaign().newDay(); + } + else { + MekHQ.triggerEvent(new WaitingToAdvanceDayEvent()); + return false; + } } } + + public void updateTOE(UUID campaignId, RemoteForce forces) { + remoteTOEs.put(campaignId, new RemoteTOE(forces)); + } + + public RemoteTOE getTOE(UUID campaignId) { + return remoteTOEs.get(campaignId); + } } diff --git a/MekHQ/src/mekhq/campaign/RemoteCampaign.java b/MekHQ/src/mekhq/campaign/RemoteCampaign.java new file mode 100644 index 0000000000..6b6c8a7788 --- /dev/null +++ b/MekHQ/src/mekhq/campaign/RemoteCampaign.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.campaign; + +import java.util.UUID; + +import org.joda.time.DateTime; + +import mekhq.campaign.universe.PlanetarySystem; + +public class RemoteCampaign { + + private final UUID id; + private final String name; + private final DateTime date; + private final PlanetarySystem location; + private final boolean isGMMode; + private final boolean isActive; + + public RemoteCampaign(UUID id, String name, DateTime date, PlanetarySystem location, boolean isGMMode, boolean isActive) { + this.id = id; + this.name = name; + this.date = date; + this.location = location; + this.isGMMode = isGMMode; + this.isActive = isActive; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public DateTime getDate() { + return date; + } + + public PlanetarySystem getLocation() { + return location; + } + + public boolean isGMMode() { + return isGMMode; + } + + public boolean isActive() { + return isActive; + } + + public RemoteCampaign withDate(DateTime newDate) { + return new RemoteCampaign(id, name, newDate, location, isGMMode, isActive); + } + + public RemoteCampaign withLocation(PlanetarySystem newLocation) { + return new RemoteCampaign(id, name, date, newLocation, isGMMode, isActive); + } + + public RemoteCampaign withGMMode(boolean isGMMode) { + return new RemoteCampaign(id, name, date, location, isGMMode, isActive); + } + + public RemoteCampaign withActive(boolean isActive) { + return new RemoteCampaign(id, name, date, location, isGMMode, isActive); + } +} diff --git a/MekHQ/src/mekhq/gui/CampaignGUI.java b/MekHQ/src/mekhq/gui/CampaignGUI.java index d5475770cd..6764722f67 100644 --- a/MekHQ/src/mekhq/gui/CampaignGUI.java +++ b/MekHQ/src/mekhq/gui/CampaignGUI.java @@ -107,6 +107,7 @@ import mekhq.campaign.universe.RandomFactionGenerator; import mekhq.gui.model.PartsTableModel; import mekhq.io.FileType; +import mekhq.online.events.CampaignListUpdatedEvent; /** * The application's main frame. @@ -146,6 +147,7 @@ public class CampaignGUI extends JPanel { /* Components for the status panel */ private JPanel statusPanel; + private JLabel lblOnline; private JLabel lblLocation; private JLabel lblRating; private JLabel lblFunds; @@ -239,6 +241,14 @@ public void showOverviewTab(boolean show) { } } + public void showOnlineTab(boolean show) { + if (show) { + addStandardTab(GuiTabType.ONLINE); + } else { + removeStandardTab(GuiTabType.ONLINE); + } + } + public void showGMToolsDialog() { GMToolsDialog gmTools = new GMToolsDialog(getFrame(), this); gmTools.setVisible(true); @@ -307,6 +317,14 @@ private void initComponents() { addStandardTab(GuiTabType.FINANCES); addStandardTab(GuiTabType.OVERVIEW); + // If we're hosting a MekHQ session, or if we're connected to + // a MekHQ session, or if we've had remote campaigns previously, + // then we should display the ONLINE tab. + if (app.isHosting() || app.isRemote() + || !getCampaignController().getRemoteCampaigns().isEmpty()) { + addStandardTab(GuiTabType.ONLINE); + } + initMain(); initTopButtons(); initStatusBar(); @@ -326,6 +344,7 @@ private void initComponents() { refreshLocation(); refreshTempAstechs(); refreshTempMedics(); + refreshOnlineStatus(); Dimension dim = Toolkit.getDefaultToolkit().getScreenSize(); @@ -969,11 +988,13 @@ private void initMain() { private void initStatusBar() { statusPanel = new JPanel(new FlowLayout(FlowLayout.LEADING, 20, 4)); + lblOnline = new JLabel(); lblRating = new JLabel(); lblFunds = new JLabel(); lblTempAstechs = new JLabel(); lblTempMedics = new JLabel(); + statusPanel.add(lblOnline); statusPanel.add(lblRating); statusPanel.add(lblFunds); statusPanel.add(lblTempAstechs); @@ -2469,7 +2490,12 @@ public void refreshLab() { } public void refreshCalendar() { - getFrame().setTitle(getCampaign().getTitle()); + if (app.isHosting() || app.isRemote()) { + getFrame().setTitle(String.format( + "%s - %s", app.isHosting() ? "HOSTING" : "CONNECTED", getCampaign().getTitle())); + } else { + getFrame().setTitle(getCampaign().getTitle()); + } } synchronized private void refreshReport() { @@ -2528,6 +2554,20 @@ private void refreshTempMedics() { lblTempMedics.setText(text); } + private void refreshOnlineStatus() { + if (app.isHosting()) { + lblOnline.setText( + String.format("HOSTING (%d connected)", + getCampaignController().getRemoteCampaigns().size())); + } else if (app.isRemote()) { + lblOnline.setText( + String.format("CONNECTED (%d playing)", + getCampaignController().getActiveCampaignCount() + 1 /*host*/)); + } else { + lblOnline.setVisible(false); + } + } + private ActionScheduler fundsScheduler = new ActionScheduler(this::refreshFunds); private ActionScheduler ratingScheduler = new ActionScheduler(this::refreshRating); @@ -2630,6 +2670,14 @@ public void handleLocationChanged(LocationChangedEvent ev) { refreshLocation(); } + @Subscribe + public void handle(CampaignListUpdatedEvent ev) { + refreshOnlineStatus(); + + MekHQ.getLogger().info(CampaignGUI.class, "handle(CampaignListUpdatedEvent)", + "There are " + (getCampaignController().getRemoteCampaigns().size() + 1) + " campaigns playing, " + (getCampaignController().getActiveCampaignCount() + 1) + " are active"); + } + public void refreshLocation() { lblLocation.setText(getCampaign().getLocation().getReport( getCampaign().getCalendar().getTime())); diff --git a/MekHQ/src/mekhq/gui/GuiTabType.java b/MekHQ/src/mekhq/gui/GuiTabType.java index cc39ad1c56..900e7d53e3 100644 --- a/MekHQ/src/mekhq/gui/GuiTabType.java +++ b/MekHQ/src/mekhq/gui/GuiTabType.java @@ -41,7 +41,8 @@ public enum GuiTabType { MEKLAB(8, "panMekLab.TabConstraints.tabTitle"), //$NON-NLS-1$ FINANCES(9, "panFinances.TabConstraints.tabTitle"), //$NON-NLS-1$ OVERVIEW(10, "panOverview.TabConstraints.tabTitle"), //$NON-NLS-1$ - CUSTOM(11, null); + ONLINE(11, "panOnline.TabConstraints.tabTitle"), //$NON-NLS-1$ + CUSTOM(16, null); private int defaultPos; private String name; @@ -88,6 +89,8 @@ public CampaignGuiTab createTab(CampaignGUI gui) { return new FinancesTab(gui, name); case OVERVIEW: return new OverviewTab(gui, name); + case ONLINE: + return new OnlineTab(gui, name); default: return null; diff --git a/MekHQ/src/mekhq/gui/OnlineTab.java b/MekHQ/src/mekhq/gui/OnlineTab.java new file mode 100644 index 0000000000..21a82a4cea --- /dev/null +++ b/MekHQ/src/mekhq/gui/OnlineTab.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.gui; + +import java.awt.Dimension; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.GridLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.util.ArrayList; +import java.util.List; +import java.util.ResourceBundle; +import java.util.UUID; + +import javax.swing.BorderFactory; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.JTree; +import javax.swing.SwingUtilities; + +import megamek.common.event.Subscribe; +import megamek.common.util.EncodeControl; +import mekhq.MekHQ; +import mekhq.campaign.CampaignController; +import mekhq.campaign.RemoteCampaign; +import mekhq.gui.model.OnlineCampaignsTableModel; +import mekhq.gui.model.RemoteOrgTreeModel; +import mekhq.online.events.CampaignListUpdatedEvent; +import mekhq.online.forces.RemoteTOE; +import mekhq.preferences.PreferencesNode; + +public final class OnlineTab extends CampaignGuiTab implements ActionListener { + + private static final long serialVersionUID = 4133071018441878778L; + + private ResourceBundle resourceMap; + + private OnlineCampaignsTableModel hostCampaignTableModel; + private JTable hostCampaignTable; + private OnlineCampaignsTableModel campaignsTableModel; + private JTable campaignsTable; + + private JTree selectedCampaignToeTree; + + OnlineTab(CampaignGUI gui, String name) { + super(gui, name); + MekHQ.registerHandler(this); + setUserPreferences(); + } + + private void setUserPreferences() { + PreferencesNode preferences = MekHQ.getPreferences().forClass(OnlineTab.class); + + // TODO: manage preferences + } + + private CampaignController getCampaignController() { + return getCampaignGui().getCampaignController(); + } + + @Override + public void initTab() { + resourceMap = ResourceBundle.getBundle("mekhq.resources.CampaignGUI", //$NON-NLS-1$ + new EncodeControl()); + + GridBagConstraints gbc; + + setName("panelOnline"); //$NON-NLS-1$ + setLayout(new GridLayout(0, 1)); + + hostCampaignTableModel = new OnlineCampaignsTableModel(getHostCampaignAsList()); + hostCampaignTable = new JTable(hostCampaignTableModel); + hostCampaignTable.getSelectionModel().addListSelectionListener(e -> { + RemoteTOE toe = getCampaignController().getTOE(getCampaignController().getHost()); + selectedCampaignToeTree.setModel(new RemoteOrgTreeModel(toe != null ? toe : RemoteTOE.EMPTY_TOE)); + }); + + JScrollPane hostCampaignScrollPane = new JScrollPane(hostCampaignTable); + JPanel hostCampaignTablePanel = new JPanel(new GridLayout(0, 1)); + hostCampaignTablePanel.setBorder(BorderFactory.createTitledBorder("Host Campaign")); + hostCampaignTablePanel.add(hostCampaignScrollPane); + hostCampaignTablePanel.setMinimumSize(new Dimension(400, 120)); + + campaignsTableModel = new OnlineCampaignsTableModel(getCampaignController().getRemoteCampaigns()); + campaignsTable = new JTable(campaignsTableModel); + campaignsTable.getSelectionModel().addListSelectionListener(e -> { + int row = e.getFirstIndex(); + if (row >= 0) { + UUID id = (UUID)campaignsTableModel.getValueAt(row, OnlineCampaignsTableModel.COL_ID); + RemoteTOE toe = getCampaignController().getTOE(id); + selectedCampaignToeTree.setModel(new RemoteOrgTreeModel(toe != null ? toe : RemoteTOE.EMPTY_TOE)); + } + }); + + JScrollPane campaignsTableScrollPane = new JScrollPane(campaignsTable); + JPanel campaignsTablePanel = new JPanel(new GridLayout(0, 1)); + campaignsTablePanel.setBorder(BorderFactory.createTitledBorder("Remote Campaigns")); + campaignsTablePanel.add(campaignsTableScrollPane); + campaignsTablePanel.setMinimumSize(new Dimension(400, 300)); + + JPanel topPanel = new JPanel(new GridBagLayout()); + if (!getCampaignController().isHost()) { + gbc = new GridBagConstraints(); + gbc.gridx = 0; + gbc.gridy = 0; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.weightx = 1.0; + gbc.weighty = 1.0; + topPanel.add(hostCampaignTablePanel, gbc); + } + + gbc = new GridBagConstraints(); + gbc.gridx = 0; + gbc.gridy = 1; + gbc.fill = GridBagConstraints.BOTH; + gbc.weightx = 1.0; + gbc.weighty = 1.0; + topPanel.add(campaignsTablePanel, gbc); + + JPanel campaignDetailsPanel = new JPanel(); + + selectedCampaignToeTree = new JTree(); + selectedCampaignToeTree.setCellRenderer(new RemoteForceRenderer()); + + campaignDetailsPanel.add(selectedCampaignToeTree); + + JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, topPanel, + campaignDetailsPanel); + splitPane.setOneTouchExpandable(true); + splitPane.setResizeWeight(0.5); + + add(splitPane); + } + + @Override + public void refreshAll() { + hostCampaignTableModel.setData(getHostCampaignAsList()); + campaignsTableModel.setData(new ArrayList<>(getCampaignController().getRemoteCampaigns())); + } + + private List getHostCampaignAsList() { + List list = new ArrayList<>(); + + list.add(new RemoteCampaign(getCampaignController().getHost(), + getCampaignController().getHostName(), getCampaignController().getHostDate(), + getCampaignController().getHostLocation(), getCampaignController().getHostIsGMMode(), + true/*isActive*/)); + + return list; + } + + @Override + public GuiTabType tabType() { + return GuiTabType.ONLINE; + } + + @Override + public void actionPerformed(ActionEvent e) { + // TODO Auto-generated method stub + + } + + @Subscribe + public void handle(CampaignListUpdatedEvent e) { + refreshAll(); + } +} diff --git a/MekHQ/src/mekhq/gui/RemoteForceRenderer.java b/MekHQ/src/mekhq/gui/RemoteForceRenderer.java new file mode 100644 index 0000000000..f9d8649fff --- /dev/null +++ b/MekHQ/src/mekhq/gui/RemoteForceRenderer.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.gui; + +import java.awt.Component; + +import javax.swing.JTree; +import javax.swing.UIManager; +import javax.swing.tree.DefaultTreeCellRenderer; + +import mekhq.online.forces.RemoteForce; +import mekhq.online.forces.RemoteUnit; + +public class RemoteForceRenderer extends DefaultTreeCellRenderer { + + private static final long serialVersionUID = 1L; + + public Component getTreeCellRendererComponent( + JTree tree, + Object value, + boolean sel, + boolean expanded, + boolean leaf, + int row, + boolean hasFocus) { + + super.getTreeCellRendererComponent( + tree, value, sel, + expanded, leaf, row, + hasFocus); + setBackground(UIManager.getColor("Tree.background")); + setForeground(UIManager.getColor("Tree.textForeground")); + if (sel) { + setBackground(UIManager.getColor("Tree.selectionBackground")); + setForeground(UIManager.getColor("Tree.selectionForeground")); + } + + if (value instanceof RemoteUnit) { + RemoteUnit u = (RemoteUnit)value; + String name = u.getCommander(); + if (name == null) { + name = "No Crew"; + } + String uname = "" + u.getName() + ""; + + setText("" + name + ", " + uname + ""); + } + if (value instanceof RemoteForce) { + RemoteForce f = (RemoteForce)value; + + setText("" + f.getName() + ""); + } + + return this; + } +} diff --git a/MekHQ/src/mekhq/gui/model/OnlineCampaignsTableModel.java b/MekHQ/src/mekhq/gui/model/OnlineCampaignsTableModel.java new file mode 100644 index 0000000000..27a0406de8 --- /dev/null +++ b/MekHQ/src/mekhq/gui/model/OnlineCampaignsTableModel.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.gui.model; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; + +import org.joda.time.format.DateTimeFormatter; +import org.joda.time.format.ISODateTimeFormat; + +import mekhq.campaign.RemoteCampaign; + +public class OnlineCampaignsTableModel extends DataTableModel { + + private static final long serialVersionUID = 5792881778901299604L; + + public final static int COL_ID = 0; + public final static int COL_NAME = 1; + public final static int COL_DATE = 2; + public final static int COL_LOCATION = 3; + public final static int COL_IS_GM = 4; + public final static int COL_IS_ACTIVE = 5; + public final static int N_COL = 6; + + private final DateTimeFormatter dateFormatter = ISODateTimeFormat.date(); + + public OnlineCampaignsTableModel() { + setData(Collections.emptyList()); + } + + public OnlineCampaignsTableModel(Collection remoteCampaigns) { + setData(new ArrayList<>(remoteCampaigns)); + } + + public int getRowCount() { + return data.size(); + } + + @Override + public int getColumnCount() { + return N_COL; + } + + @Override + public String getColumnName(int column) { + switch(column) { + case COL_ID: + return "ID"; + case COL_NAME: + return "Name"; + case COL_DATE: + return "Date"; + case COL_LOCATION: + return "Current Location"; + case COL_IS_GM: + return "GM?"; + case COL_IS_ACTIVE: + return "Active?"; + default: + return "?"; + } + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + RemoteCampaign row = (RemoteCampaign)data.get(rowIndex); + switch (columnIndex) + { + case COL_ID: + return row.getId(); + case COL_NAME: + return row.getName(); + case COL_DATE: + return dateFormatter.print(row.getDate()); + case COL_LOCATION: + return row.getLocation().getName(row.getDate()); + case COL_IS_GM: + return row.isGMMode() ? "Yes" : "No"; + case COL_IS_ACTIVE: + return "Yes"; + default: + return null; + } + } +} diff --git a/MekHQ/src/mekhq/gui/model/RemoteOrgTreeModel.java b/MekHQ/src/mekhq/gui/model/RemoteOrgTreeModel.java new file mode 100644 index 0000000000..e4ca601623 --- /dev/null +++ b/MekHQ/src/mekhq/gui/model/RemoteOrgTreeModel.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.gui.model; + +import java.util.Vector; + +import javax.swing.event.TreeModelListener; +import javax.swing.tree.TreeModel; +import javax.swing.tree.TreePath; + +import mekhq.online.forces.RemoteForce; +import mekhq.online.forces.RemoteTOE; +import mekhq.online.forces.RemoteUnit; + +public class RemoteOrgTreeModel implements TreeModel { + + private RemoteForce rootForce; + private Vector listeners = new Vector(); + + public RemoteOrgTreeModel() { + rootForce = RemoteForce.emptyForce(); + } + + public RemoteOrgTreeModel(RemoteTOE toe) { + rootForce = toe.getForces(); + } + + @Override + public Object getChild(Object parent, int index) { + if(parent instanceof RemoteForce) { + return ((RemoteForce)parent).getAllChildren().get(index); + } + return null; + } + + @Override + public int getChildCount(Object parent) { + if(parent instanceof RemoteForce) { + return ((RemoteForce)parent).getAllChildren().size(); + } + return 0; + } + + @Override + public int getIndexOfChild(Object parent, Object child) { + if(parent instanceof RemoteForce) { + return ((RemoteForce)parent).getAllChildren().indexOf(child); + } + return 0; + } + + @Override + public Object getRoot() { + return rootForce; + } + + @Override + public boolean isLeaf(Object node) { + return node instanceof RemoteUnit + || (node instanceof RemoteForce && ((RemoteForce)node).getAllChildren().size() == 0); + } + + @Override + public void valueForPathChanged(TreePath arg0, Object arg1) { + // TODO Auto-generated method stub + } + + public void addTreeModelListener( TreeModelListener listener ) { + if ( listener != null && !listeners.contains( listener ) ) { + listeners.addElement( listener ); + } + } + + public void removeTreeModelListener( TreeModelListener listener ) { + if ( listener != null ) { + listeners.removeElement( listener ); + } + } +} diff --git a/MekHQ/src/mekhq/online/MekHQClient.java b/MekHQ/src/mekhq/online/MekHQClient.java new file mode 100644 index 0000000000..76a953559c --- /dev/null +++ b/MekHQ/src/mekhq/online/MekHQClient.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.online; + +import java.util.HashSet; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.Timestamp; + +import org.joda.time.DateTime; +import org.joda.time.format.DateTimeFormatter; +import org.joda.time.format.ISODateTimeFormat; + +import io.grpc.Channel; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.stub.StreamObserver; +import megamek.common.event.Subscribe; +import mekhq.MekHQ; +import mekhq.campaign.CampaignController; +import mekhq.campaign.event.GMModeEvent; +import mekhq.campaign.event.LocationChangedEvent; +import mekhq.campaign.event.NewDayEvent; +import mekhq.campaign.event.OrganizationChangedEvent; +import mekhq.online.MekHQHostGrpc.MekHQHostBlockingStub; +import mekhq.online.MekHQHostGrpc.MekHQHostStub; +import mekhq.online.events.CampaignDetailsUpdatedEvent; +import mekhq.online.events.CampaignListUpdatedEvent; +import mekhq.online.forces.RemoteForce; + +public class MekHQClient { + private final DateTimeFormatter dateFormatter = ISODateTimeFormat.date(); + private final ResourceBundle resourceMap = ResourceBundle.getBundle("mekhq.resources.MekHQ"); + + private final MekHQHostBlockingStub blockingStub; + private final MekHQHostStub asyncStub; + private final CampaignController controller; + + private final CountDownLatch finishLatch = new CountDownLatch(1); + private StreamObserver messageBus; + private ScheduledExecutorService pingExecutor; + private ScheduledFuture pings; + + private final Set knownCampaigns = new HashSet<>(); + + public MekHQClient(Channel channel, CampaignController controller) { + blockingStub = MekHQHostGrpc.newBlockingStub(channel); + asyncStub = MekHQHostGrpc.newStub(channel); + this.controller = controller; + } + + protected mekhq.campaign.Campaign getCampaign() { + return controller.getLocalCampaign(); + } + + protected CampaignDetails getCampaignDetails() { + return CampaignDetails.newBuilder() + .setId(getCampaign().getId().toString()) + .setName(getCampaign().getName()) + .setDate(dateFormatter.print(getCampaign().getDateTime())) + .setLocation(getCampaign().getLocation().getCurrentSystem().getId()) + .setIsGMMode(getCampaign().isGM()) + .build(); + } + + public void connect() { + + ConnectionRequest request = ConnectionRequest.newBuilder() + .setVersion(resourceMap.getString("Application.version")) + .setClient(getCampaignDetails()) + .build(); + + ConnectionResponse response; + try { + response = blockingStub.connect(request); + } catch (StatusRuntimeException e) { + MekHQ.getLogger().warning(MekHQClient.class, "connect()", "RPC failed: " + e.getStatus()); + return; + } + + CampaignDetails hostCampaign = response.getHost(); + + MekHQ.getLogger().info(MekHQClient.class, "connect()", + "Connected to Campaign: " + hostCampaign.getId() + " " + hostCampaign.getDate()); + + controller.setHost(UUID.fromString(hostCampaign.getId())); + controller.setHostName(hostCampaign.getName()); + controller.setHostDate(dateFormatter.parseDateTime(hostCampaign.getDate())); + controller.setHostLocation(hostCampaign.getLocation()); + controller.setHostIsGMMode(hostCampaign.getIsGMMode()); + + knownCampaigns.add(controller.getHost()); + for (CampaignDetails details : response.getCampaignsList()) { + UUID clientId = UUID.fromString(details.getId()); + + knownCampaigns.add(clientId); + + controller.addRemoteCampaign(clientId, details.getName(), + dateFormatter.parseDateTime(details.getDate()), details.getLocation(), details.getIsGMMode(), + details.getIsActive()); + } + + createMessageBus(); + + sendMessagesForInitialConnection(); + + pingExecutor = Executors.newSingleThreadScheduledExecutor(); + pings = pingExecutor.scheduleAtFixedRate(() -> sendPing(), 0, 15, TimeUnit.SECONDS); + + MekHQ.registerHandler(this); + } + + public void disconnect() { + DisconnectionRequest request = DisconnectionRequest.newBuilder().setId(getCampaign().getId().toString()) + .build(); + + try { + DisconnectionResponse response = blockingStub.disconnect(request); + MekHQ.getLogger().info(MekHQClient.class, "disconnect()", + "Disconnected from Campaign: " + response.getId()); + } catch (StatusRuntimeException e) { + MekHQ.getLogger().warning(MekHQClient.class, "disconnect()", "RPC failed: " + e.getStatus()); + } + + controller.setHost(getCampaign().getId()); + } + + private void createMessageBus() { + messageBus = asyncStub.messageBus(new StreamObserver() { + @Override + public void onNext(ServerMessage message) { + UUID id = UUID.fromString(message.getId()); + Any payload = message.getMessage(); + try { + if (payload.is(Ping.class)) { + handlePing(id, payload.unpack(Ping.class)); + } else if (payload.is(Pong.class)) { + handlePong(id, payload.unpack(Pong.class)); + } else if (payload.is(CampaignDateChanged.class)) { + handleCampaignDateChanged(id, payload.unpack(CampaignDateChanged.class)); + } else if (payload.is(GMModeChanged.class)) { + handleGMModeChanged(id, payload.unpack(GMModeChanged.class)); + } else if (payload.is(LocationChanged.class)) { + handleLocationChanged(id, payload.unpack(LocationChanged.class)); + } else if (payload.is(TOEUpdated.class)) { + handleTOEUpdated(id, payload.unpack(TOEUpdated.class)); + } + } catch (InvalidProtocolBufferException e) { + MekHQ.getLogger().error(MekHQClient.class, "messageBus::onNext()", "RPC protocol error: " + e.getMessage(), e); + handleRpcException(e); + } + } + + @Override + public void onError(Throwable t) { + MekHQ.getLogger().error(MekHQClient.class, "messageBus::onNext()", "RPC protocol error: " + t.getMessage(), t); + finishLatch.countDown(); + } + + @Override + public void onCompleted() { + finishLatch.countDown(); + } + }); + } + + /** + * Sends messages to the host campaign upon its initial connection to the message bus. + */ + private void sendMessagesForInitialConnection() { + TOEUpdated toeUpdated = buildTOEUpdated(); + messageBus.onNext(buildMessage(toeUpdated)); + } + + protected void handleRpcException(Throwable t) { + messageBus.onError(Status.INTERNAL + .withDescription(t.getMessage()) + .augmentDescription("messageBus()") + .withCause(t) // This can be attached to the Status locally, but NOT transmitted to the client! + .asRuntimeException()); + } + + private void sendPing() { + Ping ping = Ping.newBuilder() + .setCampaign(getCampaignDetails()) + .build(); + messageBus.onNext(buildMessage(ping)); + } + + protected void handlePing(UUID id, Ping ping) { + Pong pong = Pong.newBuilder() + .setCampaign(getCampaignDetails()) + .build(); + + CampaignDetails hostCampaign = ping.getCampaign(); + + MekHQ.getLogger().info(MekHQClient.class, "handlePing()", String.format("-> PING: %s %s %s", id, hostCampaign.getDate(), hostCampaign.getLocation())); + + messageBus.onNext(buildMessage(pong)); + } + + protected void handlePong(UUID id, Pong pong) { + CampaignDetails hostCampaign = pong.getCampaign(); + + String date = hostCampaign.getDate(); + DateTime hostDate = dateFormatter.parseDateTime(date); + String locationId = hostCampaign.getLocation(); + + MekHQ.getLogger().info(MekHQClient.class, "handlePong()", String.format("-> PONG: %s %s %s (%d connected)", id, hostDate, locationId, pong.getCampaignsCount())); + + controller.setHostDate(hostDate); + controller.setHostLocation(locationId); + controller.setHostIsGMMode(hostCampaign.getIsGMMode()); + + // gather the IDs of all of the campaigns we know about + Set allCampaigns = new HashSet<>(controller.getRemoteCampaignIds()); + + for (CampaignDetails campaign : pong.getCampaignsList()) { + UUID clientId = UUID.fromString(campaign.getId()); + + // remove this campaign from the list we know about + // so that when this ends we'll be left with a set + // of campaigns that our host doesn't know about + // or isn't active anymore. We take into account the + // ACTIVE status from the Host campaign below. + allCampaigns.remove(clientId); + + controller.addRemoteCampaign(clientId, campaign.getName(), + dateFormatter.parseDateTime(campaign.getDate()), campaign.getLocation(), campaign.getIsGMMode(), + campaign.getIsActive()); + } + + // Account for any missing Campaigns on this Host + // they are all automatically considered INACTIVE. + for (UUID missingCampaignId : allCampaigns) { + controller.removeActiveCampaign(missingCampaignId); + } + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + } + + protected void handleCampaignDateChanged(UUID hostId, CampaignDateChanged dateChanged) { + String date = dateChanged.getDate(); + DateTime campaignDate = dateFormatter.parseDateTime(date); + UUID campaignId = UUID.fromString(dateChanged.getId()); + if (hostId.equals(campaignId)) { + controller.setHostDate(campaignDate); + MekHQ.getLogger().info(MekHQClient.class, "handleCampaignDateChanged()", String.format("<- HOST CampaignDateChanged: %s", date)); + } else { + controller.setRemoteCampaignDate(campaignId, campaignDate); + } + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + } + + protected void handleGMModeChanged(UUID hostId, GMModeChanged gmModeChanged) { + boolean isGMMode = gmModeChanged.getValue(); + UUID campaignId = UUID.fromString(gmModeChanged.getId()); + if (hostId.equals(campaignId)) { + controller.setHostIsGMMode(isGMMode); + MekHQ.getLogger().info(MekHQClient.class, "handleGMModeChanged()", String.format("<- HOST GMModeChanged: %s", isGMMode ? "ON" : "OFF")); + } else { + controller.setRemoteCampaignGMMode(campaignId, isGMMode); + } + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + } + + protected void handleLocationChanged(UUID hostId, LocationChanged locationChanged) { + String locationId = locationChanged.getLocation(); + boolean isGM = locationChanged.getIsGmMovement(); + UUID campaignId = UUID.fromString(locationChanged.getId()); + if (hostId.equals(campaignId)) { + controller.setHostLocation(locationId); + MekHQ.getLogger().info(MekHQClient.class, "handleLocationChanged()", String.format("<- HOST LocationChanged: %s (%s)", locationId, isGM ? "GM Moved" : "KF Jump")); + } else { + controller.setRemoteCampaignLocation(campaignId, locationId); + } + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + } + + protected void handleTOEUpdated(UUID hostId, TOEUpdated toeUpdated) { + + UUID campaignId = UUID.fromString(toeUpdated.getId()); + controller.updateTOE(campaignId, RemoteForce.build(toeUpdated.getForce())); + + MekHQ.triggerEvent(new CampaignDetailsUpdatedEvent(campaignId)); + + MekHQ.getLogger().info(MekHQClient.class, "handleTOEUpdated()", String.format("TOE Updated: %s ", campaignId)); + } + + @Subscribe + public void handle(NewDayEvent evt) { + CampaignDateChanged dateChanged = CampaignDateChanged.newBuilder() + .setId(getCampaign().getId().toString()) + .setDate(dateFormatter.print(getCampaign().getDateTime())) + .build(); + + messageBus.onNext(buildMessage(dateChanged)); + } + + @Subscribe + public void handle(GMModeEvent evt) { + GMModeChanged gmModeChanged = GMModeChanged.newBuilder() + .setId(getCampaign().getId().toString()) + .setValue(evt.isGMMode()) + .build(); + + messageBus.onNext(buildMessage(gmModeChanged)); + } + + @Subscribe + public void handle(LocationChangedEvent evt) { + LocationChanged locationChanged = LocationChanged.newBuilder() + .setId(getCampaign().getId().toString()) + .setLocation(evt.getLocation().getCurrentSystem().getId()) + .setIsGmMovement(!evt.isKFJump()) + .build(); + + messageBus.onNext(buildMessage(locationChanged)); + } + + @Subscribe + public void handle(OrganizationChangedEvent evt) { + TOEUpdated toeUpdated = buildTOEUpdated(); + + messageBus.onNext(buildMessage(toeUpdated)); + } + + private ClientMessage buildMessage(Message payload) { + return ClientMessage.newBuilder() + .setTimestamp(getTimestamp()) + .setId(getCampaign().getId().toString()) + .setMessage(Any.pack(payload)) + .build(); + } + + private static Timestamp getTimestamp() { + long millis = System.currentTimeMillis(); + return Timestamp.newBuilder().setSeconds(millis / 1000) + .setNanos((int) ((millis % 1000) * 1000000)) + .build(); + } + + private TOEUpdated buildTOEUpdated() { + return TOEUpdated.newBuilder().setId(getCampaign().getId().toString()) + .setForce(buildTOEForces(getCampaign().getForces())) + .build(); + } + + private Force buildTOEForces(mekhq.campaign.force.Force forces) { + Force.Builder forceBuilder = Force.newBuilder() + .setId(forces.getId()) + .setName(forces.getName()); + + for (Object object : forces.getAllChildren(getCampaign())) { + if (object instanceof mekhq.campaign.force.Force) { + forceBuilder.addSubForces(buildTOEForces((mekhq.campaign.force.Force) object)); + } else if (object instanceof mekhq.campaign.unit.Unit) { + forceBuilder.addUnits(buildForceUnit((mekhq.campaign.unit.Unit) object)); + } + } + + return forceBuilder.build(); + } + + private ForceUnit buildForceUnit(mekhq.campaign.unit.Unit unit) { + ForceUnit.Builder forceUnitBuilder = ForceUnit.newBuilder() + .setId(unit.getId().toString()) + .setName(unit.getName()); + + mekhq.campaign.personnel.Person commander = unit.getCommander(); + if (commander != null) { + forceUnitBuilder.setCommander(commander.getName()); + } + + return forceUnitBuilder.build(); + } +} diff --git a/MekHQ/src/mekhq/online/MekHQServer.java b/MekHQ/src/mekhq/online/MekHQServer.java new file mode 100644 index 0000000000..49dba47cd7 --- /dev/null +++ b/MekHQ/src/mekhq/online/MekHQServer.java @@ -0,0 +1,511 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.online; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.ResourceBundle; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.Timestamp; + +import org.joda.time.DateTime; +import org.joda.time.format.DateTimeFormatter; +import org.joda.time.format.ISODateTimeFormat; + +import io.grpc.Server; +import io.grpc.ServerBuilder; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import megamek.common.event.Subscribe; +import mekhq.MekHQ; +import mekhq.campaign.CampaignController; +import mekhq.campaign.RemoteCampaign; +import mekhq.campaign.event.GMModeEvent; +import mekhq.campaign.event.LocationChangedEvent; +import mekhq.campaign.event.NewDayEvent; +import mekhq.campaign.event.OrganizationChangedEvent; +import mekhq.online.MekHQHostGrpc; +import mekhq.online.events.CampaignConnectedEvent; +import mekhq.online.events.CampaignDetailsUpdatedEvent; +import mekhq.online.events.CampaignDisconnectedEvent; +import mekhq.online.events.CampaignListUpdatedEvent; +import mekhq.online.forces.RemoteForce; + +public class MekHQServer { + private final int port; + private final MekHQHostService service; + private final Server server; + + private ScheduledExecutorService pingExecutor; + private ScheduledFuture pings; + + public MekHQServer(int port, CampaignController controller) throws IOException { + this(ServerBuilder.forPort(port), port, controller); + } + + public MekHQServer(ServerBuilder serverBuilder, int port, CampaignController controller) { + this.port = port; + service = new MekHQHostService(controller); + server = serverBuilder.addService(service).build(); + } + + /** Start serving requests. */ + public void start() throws IOException { + server.start(); + + MekHQ.registerHandler(this); + + pingExecutor = Executors.newSingleThreadScheduledExecutor(); + pings = pingExecutor.scheduleAtFixedRate(() -> service.sendPings(), 1, 15, TimeUnit.SECONDS); + + MekHQ.getLogger().info(MekHQServer.class, "start()", "Server started, listening on " + port); + Runtime.getRuntime().addShutdownHook(new Thread() { + @Override + public void run() { + // Use stderr here since the logger may have been reset by its JVM shutdown + // hook. + System.err.println("*** shutting down gRPC server since JVM is shutting down"); + try { + MekHQServer.this.stop(); + } catch (InterruptedException e) { + e.printStackTrace(System.err); + } + System.err.println("*** server shut down"); + } + }); + } + + /** Stop serving requests and shutdown resources. */ + public void stop() throws InterruptedException { + if (server != null) { + server.shutdown().awaitTermination(30, TimeUnit.SECONDS); + } + } + + /** + * Await termination on the main thread since the grpc library uses daemon + * threads. + */ + private void blockUntilShutdown() throws InterruptedException { + if (server != null) { + server.awaitTermination(); + } + } + + @Subscribe + public void handle(NewDayEvent evt) { + service.notifyDayChanged(); + } + + @Subscribe + public void handle(GMModeEvent evt) { + service.notifyGMModeChanged(evt.isGMMode()); + } + + @Subscribe + public void handle(LocationChangedEvent evt) { + service.notifyLocationChanged(evt.getLocation().getCurrentSystem().getId(), !evt.isKFJump()); + } + + @Subscribe + public void handle(OrganizationChangedEvent evt) { + service.notifyTOEChanged(); + } + + private static class MekHQHostService extends MekHQHostGrpc.MekHQHostImplBase { + private final DateTimeFormatter dateFormatter = ISODateTimeFormat.date(); + private final ResourceBundle resourceMap = ResourceBundle.getBundle("mekhq.resources.MekHQ"); + + private final CampaignController controller; + + private final ConcurrentMap outstandingPings = new ConcurrentHashMap<>(); + private final ConcurrentMap> messageBus = new ConcurrentHashMap<>(); + + MekHQHostService(CampaignController controller) { + this.controller = controller; + } + + private mekhq.campaign.Campaign getCampaign() { + return controller.getLocalCampaign(); + } + + private CampaignDetails getCampaignDetails() { + return CampaignDetails.newBuilder().setId(getCampaign().getId().toString()).setName(getCampaign().getName()) + .setDate(dateFormatter.print(getCampaign().getDateTime())) + .setLocation(getCampaign().getLocation().getCurrentSystem().getId()) + .setIsGMMode(getCampaign().isGM()).setIsActive(true).build(); + } + + @Override + public void connect(ConnectionRequest request, StreamObserver responseObserver) { + String version = resourceMap.getString("Application.version"); + if (!version.equalsIgnoreCase(request.getVersion())) { + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription( + String.format("Version mismatch. Host %s. Client %s.", version, request.getVersion())) + .augmentDescription("connect()").asRuntimeException()); + return; + } + + CampaignDetails clientCampaign = request.getClient(); + + UUID id = UUID.fromString(clientCampaign.getId()); + if (getCampaign().getId().equals(id)) { + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription(String.format("Campaign %s cannot both HOST and CONNECT", id)) + .augmentDescription("connect()").asRuntimeException()); + return; + } + + ConnectionResponse response = ConnectionResponse.newBuilder().setVersion(version) + .setHost(getCampaignDetails()).addAllCampaigns(convert(controller.getRemoteCampaigns())).build(); + responseObserver.onNext(response); + + controller.addActiveRemoteCampaign(id, clientCampaign.getName(), DateTime.parse(clientCampaign.getDate()), + clientCampaign.getLocation(), clientCampaign.getIsGMMode()); + + responseObserver.onCompleted(); + + MekHQ.triggerEvent(new CampaignConnectedEvent(id)); + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + } + + @Override + public void disconnect(DisconnectionRequest request, StreamObserver responseObserver) { + UUID id = UUID.fromString(request.getId()); + + controller.removeActiveCampaign(id); + + handleDisconnection(id); + + DisconnectionResponse response = DisconnectionResponse.newBuilder().setId(getCampaign().getId().toString()) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + + MekHQ.triggerEvent(new CampaignDisconnectedEvent(id)); + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + } + + @Override + public StreamObserver messageBus(StreamObserver responseObserver) { + StreamObserver clientMessages = new StreamObserver() { + private UUID clientId; + + @Override + public void onNext(ClientMessage message) { + clientId = UUID.fromString(message.getId()); + Any payload = message.getMessage(); + try { + if (payload.is(Ping.class)) { + handlePing(responseObserver, clientId, payload.unpack(Ping.class)); + } else if (payload.is(Pong.class)) { + handlePong(responseObserver, clientId, payload.unpack(Pong.class)); + } else if (payload.is(CampaignDateChanged.class)) { + handleCampaignDateChanged(responseObserver, clientId, + payload.unpack(CampaignDateChanged.class)); + } else if (payload.is(GMModeChanged.class)) { + handleGMModeChanged(responseObserver, clientId, payload.unpack(GMModeChanged.class)); + } else if (payload.is(LocationChanged.class)) { + handleLocationChanged(responseObserver, clientId, payload.unpack(LocationChanged.class)); + } else if (payload.is(TOEUpdated.class)) { + handleTOEUpdated(responseObserver, clientId, payload.unpack(TOEUpdated.class)); + } + } catch (InvalidProtocolBufferException e) { + MekHQ.getLogger().error(MekHQHostService.class, "messageBus::onNext()", + "RPC protocol error: " + e.getMessage(), e); + responseObserver.onError(Status.INTERNAL.withDescription(e.getMessage()) + .augmentDescription("messageBus()").withCause(e) // This can be attached to the Status + // locally, but NOT transmitted to the + // client! + .asRuntimeException()); + + controller.removeActiveCampaign(clientId); + + MekHQ.triggerEvent(new CampaignDisconnectedEvent(clientId)); + } + } + + @Override + public void onError(Throwable t) { + MekHQ.getLogger().error(MekHQHostService.class, "messageBus::onError()", + String.format("RPC protocol error from client %s: %s", clientId, t.getMessage()), t); + + messageBus.remove(clientId); + + controller.removeActiveCampaign(clientId); + + if (clientId != null) { + MekHQ.triggerEvent(new CampaignDisconnectedEvent(clientId)); + } + } + + @Override + public void onCompleted() { + messageBus.remove(clientId); + responseObserver.onCompleted(); + + controller.removeActiveCampaign(clientId); + + if (clientId != null) { + MekHQ.triggerEvent(new CampaignDisconnectedEvent(clientId)); + } + } + }; + + sendMessagesForInitialConnection(responseObserver); + + return clientMessages; + } + + private void addMessageBus(UUID campaignId, StreamObserver responseObserver) { + messageBus.putIfAbsent(Objects.requireNonNull(campaignId), responseObserver); + } + + private void handleDisconnection(UUID campaignId) { + StreamObserver existingMessageBus = messageBus.remove(campaignId); + if (existingMessageBus != null) { + existingMessageBus.onCompleted(); + } + + MekHQ.triggerEvent(new CampaignDisconnectedEvent(campaignId)); + } + + public void sendPings() { + Ping ping = Ping.newBuilder().setCampaign(getCampaignDetails()).build(); + + List toRemove = new ArrayList<>(); + for (Map.Entry> client : messageBus.entrySet()) { + UUID clientId = client.getKey(); + + MekHQ.getLogger().info(MekHQHostService.class, "handlePing()", "<- PING: " + clientId); + + // If we have a PING without a PONG, this client is no longer active. + if (null != outstandingPings.put(clientId, clientId)) { + controller.removeActiveCampaign(clientId); + } + + try { + client.getValue().onNext(buildResponse(Ping.newBuilder(ping).build())); + } catch (Exception e) { + MekHQ.getLogger().error(MekHQHostService.class, "handlePing()", + "Failed to ping campaign " + clientId, e); + toRemove.add(clientId); + } + } + + for (UUID disconnectedCampaignId : toRemove) { + handleDisconnection(disconnectedCampaignId); + } + + if (!toRemove.isEmpty()) { + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + } + } + + private void handlePing(StreamObserver responseObserver, UUID campaignId, Ping ping) { + addMessageBus(campaignId, responseObserver); + + CampaignDetails clientCampaign = ping.getCampaign(); + + MekHQ.getLogger().info(MekHQHostService.class, "handlePing()", String.format("-> PING: %s %s %s", + campaignId, clientCampaign.getDate(), clientCampaign.getLocation())); + + Pong pong = Pong.newBuilder().setCampaign(getCampaignDetails()) + .addAllCampaigns(convert(controller.getRemoteCampaigns())).build(); + responseObserver.onNext(buildResponse(pong)); + } + + private void handlePong(StreamObserver responseObserver, UUID campaignId, Pong pong) { + outstandingPings.remove(campaignId); + + CampaignDetails clientCampaign = pong.getCampaign(); + UUID clientId = UUID.fromString(clientCampaign.getId()); + controller.addActiveRemoteCampaign(clientId, clientCampaign.getName(), + dateFormatter.parseDateTime(clientCampaign.getDate()), clientCampaign.getLocation(), + clientCampaign.getIsGMMode()); + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + + MekHQ.getLogger().info(MekHQHostService.class, "handlePing()", String.format("-> PONG: %s %s %s", + campaignId, clientCampaign.getDate(), clientCampaign.getLocation())); + } + + protected void handleCampaignDateChanged(StreamObserver responseObserver, UUID clientId, + CampaignDateChanged campaignDateChanged) { + + controller.setRemoteCampaignDate(clientId, dateFormatter.parseDateTime(campaignDateChanged.getDate())); + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + + sendToAllExcept(clientId, campaignDateChanged); + } + + protected void handleGMModeChanged(StreamObserver responseObserver, UUID clientId, + GMModeChanged gmModeChanged) { + + controller.setRemoteCampaignGMMode(clientId, gmModeChanged.getValue()); + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + + sendToAllExcept(clientId, gmModeChanged); + } + + protected void handleLocationChanged(StreamObserver responseObserver, UUID clientId, + LocationChanged locationChanged) { + + controller.setRemoteCampaignLocation(clientId, locationChanged.getLocation()); + + MekHQ.triggerEvent(new CampaignListUpdatedEvent()); + + sendToAllExcept(clientId, locationChanged); + } + + protected void handleTOEUpdated(StreamObserver responseObserver, UUID clientId, + TOEUpdated toeUpdated) { + + UUID campaignId = UUID.fromString(toeUpdated.getId()); + controller.updateTOE(campaignId, RemoteForce.build(toeUpdated.getForce())); + + MekHQ.triggerEvent(new CampaignDetailsUpdatedEvent(campaignId)); + + MekHQ.getLogger().info(MekHQHostService.class, "handleTOEUpdated()", String.format("TOE Updated: %s ", campaignId)); + + sendToAllExcept(clientId, toeUpdated); + } + + /** + * Sends messages to a client campaign upon its initial connection to the message bus. + */ + protected void sendMessagesForInitialConnection(StreamObserver responseObserver) { + TOEUpdated toeUpdated = buildTOEUpdated(); + responseObserver.onNext(buildResponse(toeUpdated)); + } + + public void notifyDayChanged() { + CampaignDateChanged dateChanged = CampaignDateChanged.newBuilder().setId(getCampaign().getId().toString()) + .setDate(dateFormatter.print(getCampaign().getDateTime())).build(); + + sendToAll(dateChanged); + } + + public void notifyGMModeChanged(boolean gmMode) { + GMModeChanged gmModeChanged = GMModeChanged.newBuilder().setId(getCampaign().getId().toString()) + .setValue(gmMode).build(); + + sendToAll(gmModeChanged); + } + + public void notifyLocationChanged(String locationId, boolean isGMMovement) { + LocationChanged locationChanged = LocationChanged.newBuilder().setId(getCampaign().getId().toString()) + .setLocation(locationId).setIsGmMovement(isGMMovement).build(); + + sendToAll(locationChanged); + } + + public void notifyTOEChanged() { + TOEUpdated toeUpdated = buildTOEUpdated(); + sendToAll(toeUpdated); + } + + private TOEUpdated buildTOEUpdated() { + return TOEUpdated.newBuilder().setId(getCampaign().getId().toString()) + .setForce(buildTOEForces(getCampaign().getForces())).build(); + } + + private void sendToAll(Message message) { + for (Map.Entry> client : messageBus.entrySet()) { + client.getValue().onNext(buildResponse(message)); + } + } + + private void sendToAllExcept(UUID exceptId, Message message) { + for (Map.Entry> client : messageBus.entrySet()) { + if (!client.getKey().equals(exceptId)) { + client.getValue().onNext(buildResponse(message)); + } + } + } + + private ServerMessage buildResponse(Message payload) { + return ServerMessage.newBuilder().setTimestamp(getTimestamp()).setId(getCampaign().getId().toString()) + .setMessage(Any.pack(payload)).build(); + } + + private static Timestamp getTimestamp() { + long millis = System.currentTimeMillis(); + return Timestamp.newBuilder().setSeconds(millis / 1000).setNanos((int) ((millis % 1000) * 1000000)).build(); + } + + private Collection convert(Collection remoteCampaigns) { + List converted = new ArrayList<>(); + for (RemoteCampaign remoteCampaign : remoteCampaigns) { + converted.add(CampaignDetails.newBuilder().setId(remoteCampaign.getId().toString()) + .setName(remoteCampaign.getName()).setDate(dateFormatter.print(remoteCampaign.getDate())) + .setLocation(remoteCampaign.getLocation().getId()).setIsGMMode(remoteCampaign.isGMMode()) + .setIsActive(remoteCampaign.isActive()).build()); + } + return converted; + } + + private Force buildTOEForces(mekhq.campaign.force.Force forces) { + Force.Builder forceBuilder = Force.newBuilder() + .setId(forces.getId()) + .setName(forces.getName()); + + for (Object object : forces.getAllChildren(getCampaign())) { + if (object instanceof mekhq.campaign.force.Force) { + forceBuilder.addSubForces(buildTOEForces((mekhq.campaign.force.Force) object)); + } else if (object instanceof mekhq.campaign.unit.Unit) { + forceBuilder.addUnits(buildForceUnit((mekhq.campaign.unit.Unit) object)); + } + } + + return forceBuilder.build(); + } + + private ForceUnit buildForceUnit(mekhq.campaign.unit.Unit unit) { + ForceUnit.Builder forceUnitBuilder = ForceUnit.newBuilder() + .setId(unit.getId().toString()) + .setName(unit.getName()); + + mekhq.campaign.personnel.Person commander = unit.getCommander(); + if (commander != null) { + forceUnitBuilder.setCommander(commander.getName()); + } + + return forceUnitBuilder.build(); + } + } +} diff --git a/MekHQ/src/mekhq/online/events/CampaignConnectedEvent.java b/MekHQ/src/mekhq/online/events/CampaignConnectedEvent.java new file mode 100644 index 0000000000..59e23cb068 --- /dev/null +++ b/MekHQ/src/mekhq/online/events/CampaignConnectedEvent.java @@ -0,0 +1,17 @@ +package mekhq.online.events; + +import java.util.UUID; + +import megamek.common.event.MMEvent; + +public class CampaignConnectedEvent extends MMEvent { + private final UUID id; + + public CampaignConnectedEvent(UUID id) { + this.id = id; + } + + public UUID getCampaignId() { + return id; + } +} diff --git a/MekHQ/src/mekhq/online/events/CampaignDetailsUpdatedEvent.java b/MekHQ/src/mekhq/online/events/CampaignDetailsUpdatedEvent.java new file mode 100644 index 0000000000..057c758d5a --- /dev/null +++ b/MekHQ/src/mekhq/online/events/CampaignDetailsUpdatedEvent.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.online.events; + +import java.util.UUID; + +import megamek.common.event.MMEvent; + +public class CampaignDetailsUpdatedEvent extends MMEvent { + private final UUID id; + + public CampaignDetailsUpdatedEvent(UUID id) { + this.id = id; + } + + public UUID getId() { + return id; + } +} diff --git a/MekHQ/src/mekhq/online/events/CampaignDisconnectedEvent.java b/MekHQ/src/mekhq/online/events/CampaignDisconnectedEvent.java new file mode 100644 index 0000000000..451da3982c --- /dev/null +++ b/MekHQ/src/mekhq/online/events/CampaignDisconnectedEvent.java @@ -0,0 +1,17 @@ +package mekhq.online.events; + +import java.util.UUID; + +import megamek.common.event.MMEvent; + +public class CampaignDisconnectedEvent extends MMEvent { + private final UUID id; + + public CampaignDisconnectedEvent(UUID id) { + this.id = id; + } + + public UUID getCampaignId() { + return id; + } +} diff --git a/MekHQ/src/mekhq/online/events/CampaignListUpdatedEvent.java b/MekHQ/src/mekhq/online/events/CampaignListUpdatedEvent.java new file mode 100644 index 0000000000..2c257bfbd4 --- /dev/null +++ b/MekHQ/src/mekhq/online/events/CampaignListUpdatedEvent.java @@ -0,0 +1,6 @@ +package mekhq.online.events; + +import megamek.common.event.MMEvent; + +public class CampaignListUpdatedEvent extends MMEvent { +} diff --git a/MekHQ/src/mekhq/online/events/WaitingToAdvanceDayEvent.java b/MekHQ/src/mekhq/online/events/WaitingToAdvanceDayEvent.java new file mode 100644 index 0000000000..7248647c31 --- /dev/null +++ b/MekHQ/src/mekhq/online/events/WaitingToAdvanceDayEvent.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.online.events; + +import megamek.common.event.MMEvent; + +public class WaitingToAdvanceDayEvent extends MMEvent { + +} diff --git a/MekHQ/src/mekhq/online/forces/RemoteForce.java b/MekHQ/src/mekhq/online/forces/RemoteForce.java new file mode 100644 index 0000000000..88f0a44669 --- /dev/null +++ b/MekHQ/src/mekhq/online/forces/RemoteForce.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.online.forces; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import mekhq.online.Force; + +public class RemoteForce { + private static final RemoteForce EMPTY_FORCE = new RemoteForce(-1, "", Collections.emptyList(), Collections.emptyList()); + + private final int id; + private final String name; + private final List subForces; + private final List units; + + public RemoteForce(int id, String name, List subForces, List units) { + this.id = id; + this.name = name; + this.subForces = Collections.unmodifiableList(subForces); + this.units = Collections.unmodifiableList(units); + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public List getSubForces() { + return subForces; + } + + public List getUnits() { + return units; + } + + public static RemoteForce build(Force force) { + return new RemoteForce(force.getId(), force.getName(), build(force.getSubForcesList()), RemoteUnit.build(force.getUnitsList())); + } + + public static List build(List subForces) { + List remoteForces = new ArrayList<>(subForces.size()); + for (Force force : subForces) { + remoteForces.add(build(force)); + } + return remoteForces; + } + + public List getAllChildren() { + List children = new ArrayList<>(subForces.size() + units.size()); + children.addAll(subForces); + children.addAll(units); + return children; + } + + public static RemoteForce emptyForce() { + return EMPTY_FORCE; + } +} diff --git a/MekHQ/src/mekhq/online/forces/RemoteTOE.java b/MekHQ/src/mekhq/online/forces/RemoteTOE.java new file mode 100644 index 0000000000..570284884a --- /dev/null +++ b/MekHQ/src/mekhq/online/forces/RemoteTOE.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.online.forces; + +public class RemoteTOE { + public static final RemoteTOE EMPTY_TOE = new RemoteTOE(RemoteForce.emptyForce()); + + private final RemoteForce forces; + + public RemoteTOE(RemoteForce forces) { + this.forces = forces; + } + + public RemoteForce getForces() { + return forces; + } +} diff --git a/MekHQ/src/mekhq/online/forces/RemoteUnit.java b/MekHQ/src/mekhq/online/forces/RemoteUnit.java new file mode 100644 index 0000000000..4d8c48913c --- /dev/null +++ b/MekHQ/src/mekhq/online/forces/RemoteUnit.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2020 The MegaMek Team. + * + * This file is part of MekHQ. + * + * MekHQ is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * MekHQ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with MekHQ. If not, see . + */ +package mekhq.online.forces; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import mekhq.online.ForceUnit; + +public class RemoteUnit { + private final UUID id; + private final String name; + private final String commander; + + public RemoteUnit(UUID id, String name, String commander) { + this.id = id; + this.name = name; + this.commander = commander; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public String getCommander() { + return commander; + } + + public static RemoteUnit build(ForceUnit unit) { + return new RemoteUnit(UUID.fromString(unit.getId()), unit.getName(), unit.getCommander()); + } + + public static List build(List units) { + List remoteUnits = new ArrayList<>(units.size()); + for (ForceUnit unit : units) { + remoteUnits.add(build(unit)); + } + return remoteUnits; + } +}