aboutsummaryrefslogtreecommitdiff
path: root/src/scalevalapokalypsi
diff options
context:
space:
mode:
Diffstat (limited to 'src/scalevalapokalypsi')
-rw-r--r--src/scalevalapokalypsi/Client/Client.scala178
-rw-r--r--src/scalevalapokalypsi/Client/ReceivedLineParser.scala27
-rw-r--r--src/scalevalapokalypsi/Client/StdinLineReader.scala31
-rw-r--r--src/scalevalapokalypsi/Client/Turn.scala32
-rw-r--r--src/scalevalapokalypsi/Model/Action.scala69
-rw-r--r--src/scalevalapokalypsi/Model/Adventure.scala79
-rw-r--r--src/scalevalapokalypsi/Model/Area.scala117
-rw-r--r--src/scalevalapokalypsi/Model/Entity.scala103
-rw-r--r--src/scalevalapokalypsi/Model/Item.scala20
-rw-r--r--src/scalevalapokalypsi/Server/Client.scala173
-rw-r--r--src/scalevalapokalypsi/Server/Clients.scala82
-rw-r--r--src/scalevalapokalypsi/Server/ConnectionGetter.scala25
-rw-r--r--src/scalevalapokalypsi/Server/Server.scala195
-rw-r--r--src/scalevalapokalypsi/constants/constants.scala19
-rw-r--r--src/scalevalapokalypsi/main.scala27
-rw-r--r--src/scalevalapokalypsi/utils/utils.scala28
16 files changed, 1205 insertions, 0 deletions
diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala
new file mode 100644
index 0000000..41b1003
--- /dev/null
+++ b/src/scalevalapokalypsi/Client/Client.scala
@@ -0,0 +1,178 @@
+package scalevalapokalypsi.Client
+
+import java.lang.Thread.sleep
+import java.net.Socket
+import scala.io.Source
+import scala.sys.process.stdout
+import scalevalapokalypsi.constants.*
+import scalevalapokalypsi.utils.{stringToByteArray,getNCharsFromSocket}
+import scalevalapokalypsi.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/scalevalapokalypsi/Client/ReceivedLineParser.scala b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala
new file mode 100644
index 0000000..dfcc2d2
--- /dev/null
+++ b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala
@@ -0,0 +1,27 @@
+package scalevalapokalypsi.Client
+
+import scala.collection.mutable.Buffer
+import scalevalapokalypsi.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/scalevalapokalypsi/Client/StdinLineReader.scala b/src/scalevalapokalypsi/Client/StdinLineReader.scala
new file mode 100644
index 0000000..6ba8761
--- /dev/null
+++ b/src/scalevalapokalypsi/Client/StdinLineReader.scala
@@ -0,0 +1,31 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Client/Turn.scala b/src/scalevalapokalypsi/Client/Turn.scala
new file mode 100644
index 0000000..30101c5
--- /dev/null
+++ b/src/scalevalapokalypsi/Client/Turn.scala
@@ -0,0 +1,32 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Model/Action.scala b/src/scalevalapokalypsi/Model/Action.scala
new file mode 100644
index 0000000..32f513d
--- /dev/null
+++ b/src/scalevalapokalypsi/Model/Action.scala
@@ -0,0 +1,69 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Model/Adventure.scala b/src/scalevalapokalypsi/Model/Adventure.scala
new file mode 100644
index 0000000..2bf6cfe
--- /dev/null
+++ b/src/scalevalapokalypsi/Model/Adventure.scala
@@ -0,0 +1,79 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Model/Area.scala b/src/scalevalapokalypsi/Model/Area.scala
new file mode 100644
index 0000000..c891af8
--- /dev/null
+++ b/src/scalevalapokalypsi/Model/Area.scala
@@ -0,0 +1,117 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Model/Entity.scala b/src/scalevalapokalypsi/Model/Entity.scala
new file mode 100644
index 0000000..083c37f
--- /dev/null
+++ b/src/scalevalapokalypsi/Model/Entity.scala
@@ -0,0 +1,103 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Model/Item.scala b/src/scalevalapokalypsi/Model/Item.scala
new file mode 100644
index 0000000..7979480
--- /dev/null
+++ b/src/scalevalapokalypsi/Model/Item.scala
@@ -0,0 +1,20 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Server/Client.scala b/src/scalevalapokalypsi/Server/Client.scala
new file mode 100644
index 0000000..6ce2522
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/Client.scala
@@ -0,0 +1,173 @@
+package scalevalapokalypsi.Server
+
+import java.net.Socket
+import scala.math.min
+import scalevalapokalypsi.constants.*
+import ServerProtocolState.*
+import scalevalapokalypsi.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/scalevalapokalypsi/Server/Clients.scala b/src/scalevalapokalypsi/Server/Clients.scala
new file mode 100644
index 0000000..377050d
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/Clients.scala
@@ -0,0 +1,82 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Server/ConnectionGetter.scala b/src/scalevalapokalypsi/Server/ConnectionGetter.scala
new file mode 100644
index 0000000..40830c7
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/ConnectionGetter.scala
@@ -0,0 +1,25 @@
+package scalevalapokalypsi.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/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala
new file mode 100644
index 0000000..13ca2f5
--- /dev/null
+++ b/src/scalevalapokalypsi/Server/Server.scala
@@ -0,0 +1,195 @@
+package scalevalapokalypsi.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 scalevalapokalypsi.constants.*
+import scalevalapokalypsi.Model.{Adventure,Entity,Player}
+import scalevalapokalypsi.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/scalevalapokalypsi/constants/constants.scala b/src/scalevalapokalypsi/constants/constants.scala
new file mode 100644
index 0000000..d5abb43
--- /dev/null
+++ b/src/scalevalapokalypsi/constants/constants.scala
@@ -0,0 +1,19 @@
+
+package scalevalapokalypsi.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/scalevalapokalypsi/main.scala b/src/scalevalapokalypsi/main.scala
new file mode 100644
index 0000000..9633f73
--- /dev/null
+++ b/src/scalevalapokalypsi/main.scala
@@ -0,0 +1,27 @@
+package scalevalapokalypsi
+
+import scalevalapokalypsi.Client.newClient
+import scalevalapokalypsi.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(() => new 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/scalevalapokalypsi/utils/utils.scala b/src/scalevalapokalypsi/utils/utils.scala
new file mode 100644
index 0000000..b1bac4d
--- /dev/null
+++ b/src/scalevalapokalypsi/utils/utils.scala
@@ -0,0 +1,28 @@
+package scalevalapokalypsi.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))