diff options
Diffstat (limited to 'src/main/scala')
-rw-r--r-- | src/main/scala/Client/Client.scala | 178 | ||||
-rw-r--r-- | src/main/scala/Client/ReceivedLineParser.scala | 27 | ||||
-rw-r--r-- | src/main/scala/Client/StdinLineReader.scala | 31 | ||||
-rw-r--r-- | src/main/scala/Client/Turn.scala | 32 | ||||
-rw-r--r-- | src/main/scala/Model/Action.scala | 69 | ||||
-rw-r--r-- | src/main/scala/Model/Adventure.scala | 79 | ||||
-rw-r--r-- | src/main/scala/Model/Area.scala | 117 | ||||
-rw-r--r-- | src/main/scala/Model/Entity.scala | 103 | ||||
-rw-r--r-- | src/main/scala/Model/Item.scala | 20 | ||||
-rw-r--r-- | src/main/scala/Server/Client.scala | 173 | ||||
-rw-r--r-- | src/main/scala/Server/Clients.scala | 82 | ||||
-rw-r--r-- | src/main/scala/Server/ConnectionGetter.scala | 25 | ||||
-rw-r--r-- | src/main/scala/Server/Server.scala | 195 | ||||
-rw-r--r-- | src/main/scala/Server/constants.scala | 19 | ||||
-rw-r--r-- | src/main/scala/main.scala | 26 | ||||
-rw-r--r-- | src/main/scala/utils.scala | 28 |
16 files changed, 0 insertions, 1204 deletions
diff --git a/src/main/scala/Client/Client.scala b/src/main/scala/Client/Client.scala deleted file mode 100644 index fc3e6b8..0000000 --- a/src/main/scala/Client/Client.scala +++ /dev/null @@ -1,178 +0,0 @@ -package o1game.Client - -import java.lang.Thread.sleep -import java.net.Socket -import scala.io.Source -import scala.sys.process.stdout -import o1game.constants.* -import o1game.utils.{stringToByteArray,getNCharsFromSocket} -import o1game.Client.{ReceivedLineParser,StdinLineReader,Turn} -import scala.concurrent.Future -import scala.concurrent.ExecutionContext.Implicits.global -import scala.util.{Try, Success, Failure} -import scala.collection.mutable.Buffer -import java.lang.System.currentTimeMillis - - -/** A helper enum for `Client` to keep track of communications with the server - */ -enum ServerLineState: - case WaitingForTimeLimit, - ActionDescription, - TurnIndicator, - AreaDescription, - Directions, - Items, - Entities - - -/** Creates a new client. - * - * @param name the name the client and its player should have - * @ip the ip of the server to connect to - * @port the port of the server to connect to - * @return the client created, if all was successful - */ -def newClient(name: String, ip: String, port: Int): Option[Client] = - val socket = Socket(ip, port) - val output = socket.getOutputStream - val input = socket.getInputStream - val initMsg = s"$GAME_VERSION\r\n$name\r\n" - output.write(stringToByteArray(initMsg)) - val msgLen = (PROTOCOL_VERSION_GOOD + "\r\n").length - val versionResponse = getNCharsFromSocket(input, msgLen) - if versionResponse == Some(s"$PROTOCOL_VERSION_GOOD\r\n") then - Some(Client(socket)) - else - None - - - -/** Main class for the client: handles communication with the server - * and the player. Should be initialized with `newClient`. - * - * @param socket the socket the client uses - */ -class Client(socket: Socket): - - /** Essential IO variables */ - private val input = socket.getInputStream - private val output = socket.getOutputStream - private val buffer: Array[Byte] = Array.ofDim(MAX_MSG_SIZE) - private var bufferIndex = 0 - private val serverLineParser = ReceivedLineParser() - private val stdinReader = StdinLineReader() - - private var serverLineState = ServerLineState.WaitingForTimeLimit - - /** Variables about the status of the current turn for the client */ - private var canAct = false - private var timeLimit: Long = 0 - private var lastTurnStart: Long = 0 - private var lastExecutedTurn: Long = 0 - assert( - lastTurnStart <= lastExecutedTurn, - "don't initialize with unexecuted turn" - ) - private val turnInfo = Turn() - - - /** Starts the client. This shouldn't terminate. */ - def startClient(): Unit = - - stdinReader.startReading() - - while true do - sleep(POLL_INTERVAL) - - this.readAndParseDataFromServer() - - if this.lastExecutedTurn < this.lastTurnStart then - print(this.giveTurn()) - - stdinReader.newLine().foreach((s: String) => - output.write(stringToByteArray(s+"\r\n")) - ) - - end startClient - - - private def readAndParseDataFromServer(): Unit = - var availableBytes = input.available() - while availableBytes != 0 do - val bytesRead = input.read(buffer, 0, availableBytes) - if bytesRead != -1 then - // TODO: unsafe conversion - parseDataFromServer(buffer.take(bytesRead)) - availableBytes = input.available() - - private def giveTurn(): String = - this.canAct = true - this.lastExecutedTurn = currentTimeMillis / 1000 - s"\n\n${this.turnInfo}\n${this.actionGetterIndicator}" - - private def displayAction(action: String): Unit = - println(s"$action") - if this.canAct then - print(this.actionGetterIndicator) - - private def actionGetterIndicator = - val timeOfTurnEnd = this.lastTurnStart + this.timeLimit - val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd - s"[$timeToTurnEnd]> " - - - - private def parseDataFromServer(data: Array[Byte]): Unit = - this.serverLineParser.in(data) - var nextLine: Option[String] = Some("") - while nextLine.isDefined do - nextLine = this.serverLineParser - .nextLine() - nextLine - .foreach(this.parseLineFromServer(_)) - - - private def parseLineFromServer(line: String) = - - if line == TURN_INDICATOR then - this.serverLineState = ServerLineState.TurnIndicator - - serverLineState match - - case ServerLineState.WaitingForTimeLimit => - val time = line.toLongOption - time match - case Some(t) => this.timeLimit = t - case None => print("Invalid time limit, oh no!!!") - this.serverLineState = ServerLineState.TurnIndicator - this.lastTurnStart = currentTimeMillis / 1000 - - case ServerLineState.ActionDescription => - if line.nonEmpty && line.head == ACTION_BLOCKING_INDICATOR then - this.canAct = false - this.displayAction(line.tail) - - case ServerLineState.TurnIndicator => - this.serverLineState = ServerLineState.AreaDescription - - case ServerLineState.AreaDescription => - this.turnInfo.areaDescription = line - this.serverLineState = ServerLineState.Directions - - case ServerLineState.Directions => - this.turnInfo.possibleDirections = line.split(LIST_SEPARATOR) - this.serverLineState = ServerLineState.Items // TODO: maybe use a list instead? - - case ServerLineState.Items => - this.turnInfo.visibleItems = line.split(LIST_SEPARATOR) - this.serverLineState = ServerLineState.Entities - - case ServerLineState.Entities => - this.turnInfo.visibleEntities = line.split(LIST_SEPARATOR) - this.serverLineState = ServerLineState.ActionDescription - this.lastTurnStart = currentTimeMillis() / 1000 - - end parseLineFromServer - -end Client diff --git a/src/main/scala/Client/ReceivedLineParser.scala b/src/main/scala/Client/ReceivedLineParser.scala deleted file mode 100644 index 7cbf935..0000000 --- a/src/main/scala/Client/ReceivedLineParser.scala +++ /dev/null @@ -1,27 +0,0 @@ -package o1game.Client - -import scala.collection.mutable.Buffer -import o1game.constants.* - -/** A class for checking asynchronously for received lines */ -class ReceivedLineParser: - - private var serverLineState = ServerLineState.ActionDescription - - private var bufferedData: Buffer[Byte] = Buffer.empty // TODO: suboptimal DS - - /** Add received data */ - def in(data: Array[Byte]): Unit = - this.bufferedData ++= data - - /** Read a line from the received data */ - def nextLine(): Option[String] = - val indexOfCRLF = this.bufferedData.indexOfSlice(CRLF) - if indexOfCRLF == -1 then - None - else - val splitData = this.bufferedData.splitAt(indexOfCRLF) - this.bufferedData = Buffer.from(splitData(1).drop(CRLF.length)) - Some(String(splitData(0).toArray)) - -end ReceivedLineParser diff --git a/src/main/scala/Client/StdinLineReader.scala b/src/main/scala/Client/StdinLineReader.scala deleted file mode 100644 index 42a1f40..0000000 --- a/src/main/scala/Client/StdinLineReader.scala +++ /dev/null @@ -1,31 +0,0 @@ -package o1game.Client - -import scala.concurrent.Future -import scala.concurrent.ExecutionContext.Implicits.global -import scala.io.StdIn.readLine -import scala.util.{Try, Success, Failure} - -/** This class is for taking new lines from stdin when they are available. - * reading starts when either newLine or clear or startReading are called. - */ -class StdinLineReader: - - private var nextLine: Future[String] = Future.failed(Exception()) - - /** Returns a new line of input if there are any. */ - def newLine(): Option[String] = - this.nextLine.value match - case Some(Success(s)) => - this.startReading() - Some(s) - case Some(Failure(e)) => - this.startReading() - None - case None => None - - /** Discards the line that is currently being read and restarts reading */ - def startReading(): Unit = - this.nextLine = Future(readLine()) - - -end StdinLineReader diff --git a/src/main/scala/Client/Turn.scala b/src/main/scala/Client/Turn.scala deleted file mode 100644 index 6b78811..0000000 --- a/src/main/scala/Client/Turn.scala +++ /dev/null @@ -1,32 +0,0 @@ -package o1game.Client - -/** `Turn`s represent information the client has got about a turn. - * This class exists essentially so that the client has somewhere - * to store data about turns and something to format that data with. - */ -class Turn: - - /** Description of the area the player controlled by the client is in - * at the end of the turn. */ - var areaDescription: String = "" - - /** Directions the player controlled by the client can go to. */ - var possibleDirections: Array[String] = Array.empty - - /** Items the player controlled by the client can see. */ - var visibleItems: Array[String] = Array.empty - - /** Entities the player controlled by the client can see. */ - var visibleEntities: Array[String] = Array.empty - - override def toString: String = - val itemDesc = "You can see the following items: " + - this.visibleItems.mkString(", ") - val entityDesc = "The following entities reside in the room: " + - this.visibleEntities.mkString(", ") - val directionDesc = "There are exits to " + - this.possibleDirections.mkString(", ") - (s"$areaDescription\n$directionDesc\n" + - s"\n$itemDesc\n$entityDesc") - -end Turn diff --git a/src/main/scala/Model/Action.scala b/src/main/scala/Model/Action.scala deleted file mode 100644 index 55f7f27..0000000 --- a/src/main/scala/Model/Action.scala +++ /dev/null @@ -1,69 +0,0 @@ -package o1game.Model - -/** The class `Action` represents actions that a player may take in a text adventure game. - * `Action` objects are constructed on the basis of textual commands and are, in effect, - * parsers for such commands. An action object is immutable after creation. - * @param input a textual in-game command such as “go east” or “rest” */ -class Action(input: String): - - private val commandText = input.trim.toLowerCase - private val verb = commandText.takeWhile( _ != ' ' ) - private val modifiers = commandText.drop(verb.length).trim - - def takesATurnFor(actor: Player): Boolean = - this.verb match - case "rest" => true - case "go" => actor.location.hasNeighbor(modifiers) - case "get" => actor.location.hasItem(this.modifiers) - case "drop" => actor.canDrop(this.modifiers) - case "say" => false - case other => false - - /** Causes the given player to take the action represented by this object, assuming - * that the command was understood. Returns a description of what happened as a result - * of the action (such as “You go west.”). The description is returned in an `Option` - * wrapper; if the command was not recognized, `None` is returned. */ - def execute(actor: Player): Option[String] = - val oldLocation = actor.location - val resOption: Option[(String, String)] = this.verb match - case "go" => Some(actor.go(this.modifiers)) - case "rest" => Some(actor.rest()) - case "get" => Some(actor.pickUp(this.modifiers)) - case "say" => - val to = "to" - val recipient = modifiers.reverse.takeWhile(_ != ' ').reverse - val recipientEntity = actor.location.getEntity(recipient) - val maybeTo = modifiers.slice( - modifiers.length - recipient.length - s"$to ".length, - modifiers.length - recipient.length - 1 - ) - val message = - modifiers.take(modifiers.length - recipient.length - 4) - if maybeTo == to then - recipientEntity.map(actor.sayTo(_, message)) - else - Some(actor.say(modifiers)) - case "drop" => Some(actor.drop(this.modifiers)) - case "xyzzy" => Some(( - "The grue tastes yummy.", - s"${actor.name} tastes some grue.") - ) - case other => None - -// println(resOption) -// println(actor.location.getEntities) - resOption.map(_(1)).filter(_.length > 0) - .foreach(s => - actor.location.getEntities.filter(_ != actor).foreach(_.observe(s)) - if oldLocation != actor.location then - oldLocation.getEntities.foreach(_.observe(s)) - ) - - resOption.map(_(0)) - - - /** Returns a textual description of the action object, for debugging purposes. */ - override def toString = s"$verb (modifiers: $modifiers)" - -end Action - diff --git a/src/main/scala/Model/Adventure.scala b/src/main/scala/Model/Adventure.scala deleted file mode 100644 index dfcb100..0000000 --- a/src/main/scala/Model/Adventure.scala +++ /dev/null @@ -1,79 +0,0 @@ -package o1game.Model - -import scala.collection.mutable.Map - -/** The class `Adventure` represents text adventure games. An adventure consists of a player and - * a number of areas that make up the game world. It provides methods for playing the game one - * turn at a time and for checking the state of the game. - * - * N.B. This version of the class has a lot of “hard-coded” information that pertains to a very - * specific adventure game that involves a small trip through a twisted forest. All newly created - * instances of class `Adventure` are identical to each other. To create other kinds of adventure - * games, you will need to modify or replace the source code of this class. */ -class Adventure(val playerNames: Vector[String]): - - private val middle = Area("Forest", "You are somewhere in the forest. There are a lot of trees here.\nBirds are singing.") - private val northForest = Area("Forest", "You are somewhere in the forest. A tangle of bushes blocks further passage north.\nBirds are singing.") - private val southForest = Area("Forest", "The forest just goes on and on.") - private val clearing = Area("Forest Clearing", "You are at a small clearing in the middle of forest.\nNearly invisible, twisted paths lead in many directions.") - private val tangle = Area("Tangle of Bushes", "You are in a dense tangle of bushes. It's hard to see exactly where you're going.") - private val home = Area("Home", "Home sweet home! Now the only thing you need is a working remote control.") - private val destination = home - - middle.setNeighbors(Vector("north" -> northForest, "east" -> tangle, "south" -> southForest, "west" -> clearing)) - northForest.setNeighbors(Vector("east" -> tangle, "south" -> middle, "west" -> clearing)) - southForest.setNeighbors(Vector("north" -> middle, "east" -> tangle, "south" -> southForest, "west" -> clearing)) - clearing.setNeighbors(Vector("north" -> northForest, "east" -> middle, "south" -> southForest, "west" -> northForest)) - tangle.setNeighbors(Vector("north" -> northForest, "east" -> home, "south" -> southForest, "west" -> northForest)) - home.setNeighbors(Vector("west" -> tangle)) - - clearing.addItem(Item("battery", "It's a small battery cell. Looks new.")) - southForest.addItem(Item( - "remote", - "It's the remote control for your TV.\n" + - "What it was doing in the forest, you have no idea.\n" + - "Problem is, there's no battery." - )) - - val players: Map[String, Player] = Map() - playerNames.foreach(this.addPlayer(_)) - - val entities: Map[String, Entity] = Map() - private val gruu = Entity("Gruu", northForest) - northForest.addEntity(gruu) - this.entities += gruu.name -> gruu - - /** Adds a player entity with the specified name to the game. - * - * @param name the name of the player entity to add - * @return the created player entity - */ - def addPlayer(name: String): Player = - val newPlayer = Player(name, middle) - middle.addEntity(newPlayer) - players += name -> newPlayer - newPlayer - - /** Gets the player entity with the specified name. - * - * @param name name of the player to find - * @return the player, if one with the name was found - */ - def getPlayer(name: String): Option[Player] = this.players.get(name) - - def getEntity[A >: Entity](name: String) = - this.players.getOrElse(name, this.entities.get(name)) - - /** Returns a message that is to be displayed to the player at the beginning of the game. */ - def welcomeMessage = "Generic welcome message" - - /** Plays a turn by executing the given in-game command, such as “go west”. Returns a textual - * report of what happened, or an error message if the command was unknown. In the latter - * case, no turns elapse. */ - def playTurnOfPlayer(playerName: String, command: String): Option[String] = - val action = Action(command) - val actor = this.players.get(playerName) - actor.flatMap(action.execute(_)) - -end Adventure - diff --git a/src/main/scala/Model/Area.scala b/src/main/scala/Model/Area.scala deleted file mode 100644 index 6721957..0000000 --- a/src/main/scala/Model/Area.scala +++ /dev/null @@ -1,117 +0,0 @@ -package o1game.Model - -import scala.collection.mutable.Map - -/** The class `Area` represents locations in a text adventure game world. A game world - * consists of areas. In general, an “area” can be pretty much anything: a room, a building, - * an acre of forest, or something completely different. What different areas have in - * common is that players can be located in them and that they can have exits leading to - * other, neighboring areas. An area also has a name and a description. - * @param name the name of the area - * @param description a basic description of the area (typically not including information about items) */ -class Area(val name: String, var description: String): - - private val neighbors = Map[String, Area]() - private val items: Map[String, Item] = Map() - private val entities: Map[String, Entity] = Map() - - /** Returns the area that can be reached from this area by moving in the given direction. The result - * is returned in an `Option`; `None` is returned if there is no exit in the given direction. */ - def neighbor(direction: String): Option[Area] = - this.neighbors.get(direction) - - def getNeighborNames: Iterable[String] = this.neighbors.keys - def getItemNames: Iterable[String] = this.items.keys - def getEntityNames: Iterable[String] = this.entities.values.map(_.name) - def getEntity(name: String): Option[Entity] = this.entities.get(name) - def getEntities: Iterable[Entity] = this.entities.values - - /** Tells whether this area has a neighbor in the given direction. - * - * @param direction the direction to check - * @return whether there is a neighbor in the direction - */ - def hasNeighbor(direction: String): Boolean = - this.neighbors.contains(direction) - - /** Adds an exit from this area to the given area. The neighboring area is reached by moving in - * the specified direction from this area. */ - def setNeighbor(direction: String, neighbor: Area) = - this.neighbors += direction -> neighbor - - /** Adds exits from this area to the given areas. Calling this method is equivalent to calling - * the `setNeighbor` method on each of the given direction–area pairs. - * @param exits contains pairs consisting of a direction and the neighboring area in that direction - * @see [[setNeighbor]] */ - def setNeighbors(exits: Vector[(String, Area)]) = - this.neighbors ++= exits - - /** Adds the specified item - * - * @param item the item to add - */ - def addItem(item: Item): Unit = this.items += item.name -> item - - /** Adds multiple items - * - * @param items a once iterable collection of items to add - */ - def addItems(items: IterableOnce[Item]) = - items.iterator.foreach(i => this.items += i.name -> i) - - def hasItem(itemName: String) = this.items.contains(itemName) - - - /** Removes the specified item if it exists. - * - * @param itemName the name of the item to remove - * @return an option containing the removed item - */ - def removeItem(itemName: String): Option[Item] = - this.items.remove(itemName) - - /** Adds the specified entity to the area. - * - * @param entity the entity to add. - */ - def addEntity(entity: Entity): Unit = - this.entities += entity.name.toLowerCase -> entity - - /** Removes the entity with the name `entityName`. - * - * @param entityName the name of the entity to remove - * @return an option containing the removed entity if it was in the area - */ - def removeEntity(entityName: String): Option[Entity] = - this.entities.remove(entityName.toLowerCase()) - - /** Returns a multi-line description of the area as a player sees it. This includes a basic - * description of the area as well as information about exits and items. If there are no - * items present, the return value has the form "DESCRIPTION\n\nExits available: - * DIRECTIONS SEPARATED BY SPACES". If there are one or more items present, the return - * value has the form "DESCRIPTION\nYou see here: ITEMS SEPARATED BY SPACES\n\nExits available: - * DIRECTIONS SEPARATED BY SPACES". The items and directions are listed in an arbitrary order. */ - def fullDescription: String = - val exitList = this.neighbors.keys.mkString(" ") - val itemList = this.items.keys.mkString(" ") - val entityList = this.getEntityNames.mkString(" ") - val itemDescription = - if this.items.nonEmpty then - s"\nYou see here: ${itemList}" - else "" - val entityDescription = - if this.entities.nonEmpty then - s"\nThere are entities: ${entityList}" - else "" - (this.description + - itemDescription + - entityDescription + - s"\n\nExits available: $exitList") - - - /** Returns a single-line description of the area for debugging purposes. */ - override def toString = - this.name + ": " + this.description.replaceAll("\n", " ").take(150) - -end Area - diff --git a/src/main/scala/Model/Entity.scala b/src/main/scala/Model/Entity.scala deleted file mode 100644 index d8e8559..0000000 --- a/src/main/scala/Model/Entity.scala +++ /dev/null @@ -1,103 +0,0 @@ -package o1game.Model - -import scala.collection.mutable.{Buffer,Map} - - - -/** A `Player` object represents a player character controlled by one real-life player - * of the program. - * - * A player object’s state is mutable: the player’s location and possessions can change, - * for instance. - * - * @param startingArea the player’s initial location */ -class Player(name: String, initialLocation: Area) extends Entity(name, initialLocation): - - private val observations: Buffer[String] = Buffer.empty - - override def observe(observation: String): Unit = - this.observations.append(observation) - - def readAndClearObservations(): Vector[String] = - val res = this.observations.toVector - observations.clear() - res - -end Player - -/** An in-game entity. - * - * @param name the name of the entity - * @param initialLocation the Area where the entity is instantiated - */ -class Entity(val name: String, initialLocation: Area): - private var currentLocation: Area = initialLocation - private var quitCommandGiven = false // one-way flag - private val inventory: Map[String, Item] = Map() - - /** Determines if the player has indicated a desire to quit the game. */ - def hasQuit = this.quitCommandGiven // TODO: This is probably unneccessary? - - /** Does nothing, except possibly in inherited classes. */ - def observe(observation: String): Unit = - println("no observation made.") - () - - /** Returns the player’s current location. */ - def location = this.currentLocation - - /** Attempts to move the player in the given direction. This is successful if there - * is an exit from the player’s current location towards the direction name. Returns - * a description of the result: "You go DIRECTION." or "You can't go DIRECTION." */ - def go(direction: String): (String, String) = - val destination = this.location.neighbor(direction) - if destination.isDefined then - val removeSuccess = this.currentLocation.removeEntity(this.name) - assert(removeSuccess.isDefined) // Production - assertions off - this.currentLocation = destination.getOrElse(this.currentLocation) - destination.foreach(_.addEntity(this)) - (s"You go $direction.", s"$name goes $direction") - else - ( - s"You can't go $direction.", - s"$name tries to go $direction and stumbles in their feet." - ) - - def pickUp(itemName: String): (String, String) = - this.currentLocation.removeItem(itemName) match - case Some(i) => - this.inventory += i.name -> i - (s"You pick up the ${i.name}", s"$name picks up the ${i.name}") - case None => (s"There is no $itemName here to pick up.", "WHAAAT THIS SHOULDN'T HAPPEN???") - - def drop(itemName: String): (String, String) = - this.inventory.remove(itemName) match - case Some(item) => - this.currentLocation.addItem(item) - (s"You drop the $itemName", s"$name drops the $itemName") - case None => ("You don't have that!", s"$name reaches their backpack to drop $itemName but miserably fails to find it there.") - - def sayTo(entity: Entity, message: String): (String, String) = - entity.observe(s"Alice: \"$message\"") - (s"You say so to ${entity.name}.", "") - - def say(message: String): (String, String) = - ("You say that aloud.", s"$name: \"$message\"") - - /** Tells whether this entity can drop the specified item - * (if an action were to specify so). - * - * @param itemName the name to check - * @return whether this entity has this item and can drop it - */ - def canDrop(itemName: String): Boolean = this.inventory.contains(itemName) - - /** Causes the player to rest for a short while (this has no substantial effect in game terms). - * Returns a description of what happened. */ - def rest(): (String, String) = - ("You rest for a while. Better get a move on, though.", "") - - /** Returns a brief description of the player’s state, for debugging purposes. */ - override def toString = "Now at: " + this.location.name - -end Entity diff --git a/src/main/scala/Model/Item.scala b/src/main/scala/Model/Item.scala deleted file mode 100644 index 229828d..0000000 --- a/src/main/scala/Model/Item.scala +++ /dev/null @@ -1,20 +0,0 @@ -package o1game.Model - -import scala.annotation.targetName - -/** The class `Item` represents items in a text adventure game. Each item has a name - * and a longer description. (In later versions of the adventure game, items may - * have other features as well.) - * - * N.B. It is assumed, but not enforced by this class, that items have unique names. - * That is, no two items in a game world have the same name. - * - * @param name the item’s name - * @param description the item’s description */ -class Item(val name: String, val description: String): - - /** Returns a short textual representation of the item (its name, that is). */ - override def toString = this.name - -end Item - diff --git a/src/main/scala/Server/Client.scala b/src/main/scala/Server/Client.scala deleted file mode 100644 index 3cd2b36..0000000 --- a/src/main/scala/Server/Client.scala +++ /dev/null @@ -1,173 +0,0 @@ -package o1game.Server - -import java.net.Socket -import scala.math.min -import o1game.constants.* -import ServerProtocolState.* -import o1game.Model.{Action,Player,Entity} - -class Client(val socket: Socket): - private var incompleteMessage: Array[Byte] = - Array.fill(MAX_MSG_SIZE)(0.toByte) - private var incompleteMessageIndex = 0 - private var protocolState = WaitingForVersion - private var outData: String = "" - private var character: Option[Player] = None - private var protocolIsIntact = true - private var name: Option[String] = None - private var nextAction: Option[Action] = None - - /** Calculates the amount of bytes available for future incoming messages */ - def spaceAvailable: Int = MAX_MSG_SIZE - incompleteMessageIndex - - /** Tests whether the client has behaved according to protocol. - * - * @return false if there has been a protocol violation, true otherwise - */ - def isIntactProtocolWise: Boolean = this.protocolIsIntact - - /** Marks that this client misbehaved in eyes of the protocol */ - def failedProtocol(): Unit = this.protocolIsIntact = false - - /** Tests whether this client is initialized and ready to start the game - * - * @return true if the client is ready to join the game - */ - def isReadyForGameStart: Boolean = - this.protocolState == WaitingForGameStart - - /** Signals this client that it's joining the game. This is important so - * that this object knows to update its protocol state. - */ - def gameStart(): Unit = this.protocolState = InGame - - /** Returns the player this client controls in the model. - * - * @return an option containing the player - */ - def player: Option[Player] = this.character - - /** Tells this client object that it controls the specified player. - * - * @param player the player this client is to control - */ - def givePlayer(player: Player): Unit = - this.character = Some(player) - - /** Gets the name of this client, which should match the name of the player - * that is given to this client. Not very useful if the client hasn't yet - * received the name or if it already has an player. - * - * @return the name of this client - */ - def getName: Option[String] = this.name - - /** Sets `data` as received for the client. - * - * @return false means there was not enough space to receive the message - */ - def receiveData(data: Vector[Byte]): Boolean = - for i <- 0 until min(data.length, spaceAvailable) do - this.incompleteMessage(this.incompleteMessageIndex + i) = data(i) - this.incompleteMessageIndex += data.length - this.incompleteMessageIndex = - min(this.incompleteMessageIndex, MAX_MSG_SIZE) - data.length < spaceAvailable - - /** Returns data that should be sent to this client. - * The data is cleared when calling. - */ - def dataToThisClient(): String = - val a = this.outData - this.outData = "" - a - - /** Specifies that the data should be buffered for - * sending to this client - * - * @param data data to buffer for sending - */ - private def addDataToSend(data: String): Unit = - this.outData += s"$data\r\n" - - - /** Returns one line of data if there are any line breaks. - * Removes the parsed data from the message buffering area. - */ - private def nextLine(): Option[String] = - var nextCRLF = this.incompleteMessage.indexOf(CRLF(0)) - if this.incompleteMessage(nextCRLF + 1) != CRLF(1) then nextCRLF = -1 - if nextCRLF != -1 then - val message = this.incompleteMessage.take(nextCRLF) - val rest = this.incompleteMessage.drop(nextCRLF + 2) - this.incompleteMessage = rest ++ Array.fill(nextCRLF + 1)(0.toByte) - // TODO: the conversion may probably be exploited to crash the server - Some(String(message)) - else - None - - /** Makes the client play its turn */ - def act(): Unit = - this.addDataToSend(ACTION_BLOCKING_INDICATOR.toString) - this.nextAction.foreach(a => this.addDataToSend( - s"$ACTION_BLOCKING_INDICATOR${this.executeAction(a)}" - )) - this.nextAction = None - - /** Checks whether the client has chosen its next action - * - * @return whether the client is ready to act */ - def isReadyToAct: Boolean = this.nextAction.isDefined - - /** Causes the client to interpret the data it has received */ - def interpretData(): Unit = - LazyList.continually(this.nextLine()) - .takeWhile(_.isDefined) - .flatten - .foreach(s => interpretLine(s)) - - /** Makes the client execute the action specified by `line`. - * If there is a protocol error, the function changes - * the variable `protocolIsIntact` to false. - * - * @param line the line to interpret - */ - private def interpretLine(line: String): Unit = - this.protocolIsIntact = this.protocolState match - case WaitingForVersion => - if line == GAME_VERSION then - addDataToSend(s"$PROTOCOL_VERSION_GOOD") - this.protocolState = WaitingForClientName - true - else - addDataToSend(s"$PROTOCOL_VERSION_BAD") - false - case WaitingForClientName => - this.name = Some(line) - this.protocolState = WaitingForGameStart - true - case WaitingForGameStart => true - case InGame => - this.bufferAction(Action(line)) - true - - /** Buffers the action for execution or executes it immediately if it - * doesn't take a turn */ - private def bufferAction(action: Action) = - if ( - this.nextAction.isEmpty && - this.player.exists(action.takesATurnFor(_)) - ) then - this.nextAction = Some(action) - else if this.nextAction.isEmpty then - this.addDataToSend(s"$ACTION_NONBLOCKING_INDICATOR${this.executeAction(action)}") - - /** Executes the specified action and returns its description */ - private def executeAction(action: Action): String = - this.character.flatMap(action.execute(_)) match - case Some(s) => s - case None => "You can't do that" - - -end Client - diff --git a/src/main/scala/Server/Clients.scala b/src/main/scala/Server/Clients.scala deleted file mode 100644 index 6487446..0000000 --- a/src/main/scala/Server/Clients.scala +++ /dev/null @@ -1,82 +0,0 @@ -package o1game.Server - -import scala.util.Try -import scala.util.Random - -class Clients(maxClients: Int): - private val clients: Array[Option[Client]] = Array.fill(maxClients)(None) - - /** Adds `client` to this collection of clients. - * - * @param client the Client to add - * @return true if there was room for the client - * i.e. fewer clients than `maxClients`, false otherwise - */ - def addClient(client: Client): Boolean = - val i = this.clients.indexOf(None) - if i == -1 then - false - else - this.clients(i) = Some(client) - true - - /** Returns all the clients. - * - * @return an iterable of all the clients - */ - def allClients: Iterable[Client] = clients.toVector.flatten - - /** Applies the function `f` to all the clients for its side effects. */ - def foreach(f: Client => Any): Unit = this.clients.flatten.foreach(f) - - /** Executes the function `f` for all clients in a pseudorandom order. */ - def inRandomOrder(f: Client => Any): Unit = - Random.shuffle(this - .clients - .flatten) - .foreach(f) - - /** Returns true if the predicate `f` stands for all clients, - * false otherwise - * - * @param f the predicate to check for all clients - * @return whether `f` stands for all clients - */ - def forall(f: Client => Boolean): Boolean = this.clients.flatten.forall(f) - - /** Gets the names of all the clients stored by this object. - * - * @return the names of the clients - */ - def names: Vector[String] = this.clients.flatten.flatMap(_.getName).toVector - - def isEmpty: Boolean = this.clients.flatten.isEmpty - - /** Applies the function `f` to all the clients for its side effects - * and removes all the clients for which `f([client])` returns false. - * This is useful for doing IO with the client and removing clients - * with stale sockets. - * - * @param f the function to apply to all the clients and filter them with - */ - def removeNonSatisfying(f: Client => Boolean): Unit = - for i <- this.clients.indices do - this.clients(i) match - case Some(c) => - if !f(c) then - this.clients(i) = None - case None => - - /** Removes clients that have not behaved according to protocol */ - def removeNonCompliant(): Unit = - this.removeNonSatisfying(_.isIntactProtocolWise) - - /** Applies the function f to all clients for its side effects. - * If the function throws an exception, the client is removed. - * Probably a more concise alternative to `removeNonSatisfying`, - * but might catch exceptions unintentionally. - * - * @param f the function to apply for its side effects to each client - */ - def mapAndRemove(f: Client => Unit): Unit = - this.removeNonSatisfying(c => Try(f(c)).isSuccess) diff --git a/src/main/scala/Server/ConnectionGetter.scala b/src/main/scala/Server/ConnectionGetter.scala deleted file mode 100644 index b3246a7..0000000 --- a/src/main/scala/Server/ConnectionGetter.scala +++ /dev/null @@ -1,25 +0,0 @@ -package o1game.Server - -import java.io.IOException -import java.net.{ServerSocket, Socket} -import scala.concurrent.Future -import scala.util.{Failure, Success} -import scala.concurrent.ExecutionContext.Implicits.global - -/** Small helper class for getting new connections using futures */ -class ConnectionGetter(val socket: ServerSocket): - - private var nextClient: Future[Socket] = Future.failed(IOException()) - - /** Returns a new socket to a client if there is any new connections. */ - def newClient(): Option[Socket] = - this.nextClient.value match - case Some(Success(s)) => - nextClient = Future(socket.accept()) - Some(s) - case Some(Failure(e)) => - nextClient = Future(socket.accept()) - None - case None => None - -end ConnectionGetter diff --git a/src/main/scala/Server/Server.scala b/src/main/scala/Server/Server.scala deleted file mode 100644 index 7864c49..0000000 --- a/src/main/scala/Server/Server.scala +++ /dev/null @@ -1,195 +0,0 @@ -package o1game.Server - - -// TODO: TLS/SSL / import javax.net.ssl.SSLServerSocketFactory - -import java.lang.Thread.{currentThread, sleep} -import java.io.IOException -import java.net.{ServerSocket, Socket} -import o1game.constants.* -import o1game.Model.{Adventure,Entity,Player} -import o1game.utils.stringToByteArray - -import java.lang.System.currentTimeMillis -import scala.util.Try - - -/** `Server` exists to initialize a server for the game - * and run it with its method `startServer`. - * - * @param port the TCP port the server should listen on - * @param maxClients the maximum number of clients that may be in the game - * simultaneously. - * @param timeLimit the time limit clients should have to execute their turns. - * @param joinAfterStart whether new clients are accepted after the game has - * been started - */ -class Server( - port: Int, - maxClients: Int, - val timeLimit: Int, - val joinAfterStart: Boolean -): - - private val socket = ServerSocket(port) - private val clientGetter = ConnectionGetter(socket) - private val clients: Clients = Clients(maxClients) - private val buffer: Array[Byte] = Array.ofDim(1024) - private var bufferIndex = 0 - private var adventure: Option[Adventure] = None - private var previousTurn = 0.0 - - /** Starts the server. Won't terminate under normal circumstances. */ - def startServer(): Unit = - while true do - this.serverStep() - sleep(POLL_INTERVAL) - - private def serverStep(): Unit = - this.clients.removeNonCompliant() - if this.adventure.isEmpty || this.joinAfterStart then - this.receiveNewClient() - this.readFromAll() - this.clients.foreach(_.interpretData()) - this.writeClientDataToClients() - this.writeObservations() - if this.canExecuteTurns then - this.clients.inRandomOrder(_.act()) - this.writeClientDataToClients() - this.writeObservations() - this.clients.foreach(c => - this.writeToClient(this.turnStartInfo(c), c) - ) - this.previousTurn = currentTimeMillis() / 1000 - if this.adventure.isDefined && this.joinAfterStart then - this.clients.foreach( c => if c.isReadyForGameStart then - this.adventure.foreach(a => - c.getName.foreach(n => a.addPlayer(n)) - ) - startGameForClient(c) - ) - else if this.adventure.isEmpty && !this.clients.isEmpty && this.clients.forall(_.isReadyForGameStart) then - this.adventure = Some(Adventure(this.clients.names)) - this.clients.foreach(startGameForClient(_)) - this.previousTurn = currentTimeMillis() / 1000 - - /** Helper function to start the game for the specified client c. - * MAY ONLY BE USED IF `this.adventure` is Some! - * Apparently guard clauses are bad because they use return or something, - * but assertions should be fine, as long as they enforce the function - * contract? - */ - private def startGameForClient(c: Client): Unit = - assert(this.adventure.isDefined) - c.gameStart() - val name = c.getName - - val playerEntity: Option[Player] = name match - case Some(n) => this.adventure match - case Some(a) => a.getPlayer(n) - case None => None - case None => None - playerEntity.foreach(c.givePlayer(_)) - - this.writeToClient( - s"$timeLimit\r\n${this.turnStartInfo(c)}", c - ) - - this.clients.foreach(c => - if c.player != playerEntity then - c.player.foreach(_.observe(s"${name.getOrElse("Unknown player")} joins the game.")) - ) - - - private def writeObservations(): Unit = - this.clients.foreach(c => - val observations = c.player.map(_.readAndClearObservations()) - observations.foreach(_.foreach((s: String) => - this.writeToClient(s"$ACTION_NONBLOCKING_INDICATOR$s\r\n", c)) - ) - ) - - /** Helper function to determine if the next turn can be taken */ - private def canExecuteTurns: Boolean = - val requirement1 = this.adventure.isDefined - val requirement2 = !this.clients.isEmpty // nice! you can just return - // to the game after everyone - // left and everything is just - // as before! - val allPlayersReady = this.clients.forall(_.isReadyToAct) - val requirement3 = (allPlayersReady - || currentTimeMillis() / 1000 >= previousTurn + timeLimit) - requirement1 && requirement2 && requirement3 - - - /** Receives a new client and stores it in `clients`. - * - * @return describes if a client was added - */ - private def receiveNewClient(): Boolean = - this.clientGetter.newClient() match - case Some(c) => - clients.addClient(Client(c)) - true - case None => - false - - private def turnStartInfo(client: Client): String = - val clientArea = client.player.map(_.location) - val areaDesc = clientArea - .map(_.description) - .getOrElse("You are floating in the middle of a soothing void.") - val directions = clientArea - .map(_.getNeighborNames.mkString(LIST_SEPARATOR)) - .getOrElse("") - val items = clientArea - .map(_.getItemNames.mkString(LIST_SEPARATOR)) - .getOrElse("") - val entities = client.player.map(c => - c.location - .getEntityNames - .filter(c.name != _) - .mkString(LIST_SEPARATOR) - ).getOrElse("") - s"$TURN_INDICATOR\r\n$areaDesc\r\n$directions\r\n$items\r\n$entities\r\n" - - /** Sends `message` to all clients - * - * @param message the message to send - */ - private def writeToAll(message: String): Unit = - this.clients.mapAndRemove(c => - val output = c.socket.getOutputStream - output.write(stringToByteArray(message)) - output.flush() - ) - - private def writeToClient(message: String, client: Client): Unit = - try { - val output = client.socket.getOutputStream - output.write(stringToByteArray(message)) - output.flush() - } catch { - case e: IOException => client.failedProtocol() - } - - /** Sends every client's `dataToThisClient` to the client */ - private def writeClientDataToClients(): Unit = - this.clients.mapAndRemove(c => - val output = c.socket.getOutputStream - val data = c.dataToThisClient() - output.write(stringToByteArray(data)) - output.flush() - ) - - /** Reads data sent by clients and stores it in the `Client`s of `clients` */ - private def readFromAll(): Unit = - clients.mapAndRemove(c => - val input = c.socket.getInputStream - while input.available() != 0 do - val bytesRead = input.read(buffer) - if bytesRead != -1 then - c.receiveData(buffer.take(bytesRead).toVector) - ) - -end Server diff --git a/src/main/scala/Server/constants.scala b/src/main/scala/Server/constants.scala deleted file mode 100644 index 083db4e..0000000 --- a/src/main/scala/Server/constants.scala +++ /dev/null @@ -1,19 +0,0 @@ - -package o1game.constants - -val MAX_MSG_SIZE = 1024 // bytes -val CRLF: Vector[Byte] = Vector(13.toByte, 10.toByte) -val POLL_INTERVAL = 100 // millisec. -val GAME_VERSION = "0.1.0" -val TURN_INDICATOR = ">" -val ACTION_BLOCKING_INDICATOR='.' -val ACTION_NONBLOCKING_INDICATOR='+' - -val LIST_SEPARATOR=";" - -val PROTOCOL_VERSION_GOOD = "1" -val PROTOCOL_VERSION_BAD = "0" -//assert(PROTOCOL_VERSION_BAD.length <= PROTOCOL_VERSION_GOOD.length) - -enum ServerProtocolState: - case WaitingForVersion, WaitingForClientName, WaitingForGameStart, InGame diff --git a/src/main/scala/main.scala b/src/main/scala/main.scala deleted file mode 100644 index d68664a..0000000 --- a/src/main/scala/main.scala +++ /dev/null @@ -1,26 +0,0 @@ - -import o1game.Client.newClient -import o1game.Server.Server -import java.lang.Thread -import scala.concurrent.Future -import scala.concurrent.ExecutionContext.Implicits.global - -import scala.io.StdIn.readLine - -// TODO: add proper logic for starting the game -@main def main(): Unit = - print("How do you want to play?\n1) Host and join local game\n2) Join local game\n> ") - readLine().toIntOption match - case Some(1) => - Thread(() => Server(2267, 5, 30, true).startServer()).start() - println("Server started in background.") - print("Choose a name:\n> ") - val name = readLine() - newClient(name, "127.0.0.1", 2267).map(_.startClient()) - case Some(2) => - print("Choose a name:\n> ") - val name = readLine() - newClient(name, "127.0.0.1", 2267).map(_.startClient()) - case _ => println("Invalid input") - - diff --git a/src/main/scala/utils.scala b/src/main/scala/utils.scala deleted file mode 100644 index cfca568..0000000 --- a/src/main/scala/utils.scala +++ /dev/null @@ -1,28 +0,0 @@ -package o1game.utils - -import java.io.InputStream - -/** Converts this string to an array of bytes (probably for transmission). - * - * @param str the string to convert - * @return an array of bytes representing the string in UTF8. - */ -def stringToByteArray(str: String): Array[Byte] = - str.toVector.map(_.toByte).toArray - -/** Reads n characters from the given InputStream blockingly. - * - * @param input the InputStream to read from - * @param n the number of bytes to read - * @return The read result, or None in case of failure - */ -def getNCharsFromSocket(input: InputStream, n: Int): Option[String] = - val buffer: Array[Byte] = Array.ofDim(n) - var i = 0 - var failed = false - while i < n && !failed do - val res = input.read(buffer, i, n - i) - if res < 0 then failed = true - i += res - // TODO: better error handling - if failed then None else Some(String(buffer)) |