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