From 607b43a84d3bc8edffa05c722c7b8c3e6f72e964 Mon Sep 17 00:00:00 2001 From: Joel Kronqvist Date: Thu, 21 Nov 2024 21:09:52 +0200 Subject: Wooooohooo time limits and correcter printing --- src/scalevalapokalypsi/Client/Client.scala | 85 ++++++++++++------------ src/scalevalapokalypsi/Client/GameEvent.scala | 11 +++ src/scalevalapokalypsi/Client/Turn.scala | 4 +- src/scalevalapokalypsi/constants/constants.scala | 3 +- src/scalevalapokalypsi/main.scala | 59 +++++++++++++++- 5 files changed, 113 insertions(+), 49 deletions(-) create mode 100644 src/scalevalapokalypsi/Client/GameEvent.scala diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala index aab6bc3..75ce2e7 100644 --- a/src/scalevalapokalypsi/Client/Client.scala +++ b/src/scalevalapokalypsi/Client/Client.scala @@ -1,12 +1,11 @@ package scalevalapokalypsi.Client -import java.lang.Thread.sleep -import java.net.Socket +import java.net.{Socket,InetSocketAddress} import scala.io.Source import scala.sys.process.stdout import scalevalapokalypsi.constants.* import scalevalapokalypsi.utils.{stringToByteArray,getNCharsFromSocket} -import scalevalapokalypsi.Client.{ReceivedLineParser,StdinLineReader,Turn} +import scalevalapokalypsi.Client.{ReceivedLineParser,StdinLineReader,RoomState} import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global import scala.util.{Try, Success, Failure} @@ -34,7 +33,8 @@ enum ServerLineState: * @return the client created, if all was successful */ def newClient(name: String, ip: String, port: Int): Option[Client] = - val socket = Socket(ip, port) + val socket = Socket() + socket.connect(new InetSocketAddress(ip, port), INITIAL_CONN_TIMEOUT) val output = socket.getOutputStream val input = socket.getInputStream val initMsg = s"$GAME_VERSION\r\n$name\r\n" @@ -61,7 +61,6 @@ class Client(socket: Socket): 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 @@ -70,41 +69,50 @@ class Client(socket: Socket): private var timeLimit: Long = 0 private var lastTurnStart: Long = 0 private var lastExecutedTurn: Long = 0 - private var isSinging: Boolean = false + private var lineToSing: Option[String] = None private val bufferedActions: Buffer[String] = Buffer.empty assert( lastTurnStart <= lastExecutedTurn, "don't initialize with unexecuted turn" ) - private val turnInfo = Turn() + private val turnInfo = RoomState() - /** Starts the client. This shouldn't terminate. */ - def startClient(): Unit = - - stdinReader.startReading() - - while true do - - sleep(POLL_INTERVAL) + /** Takes a client step and optionally returns an in-game event for UI + * + * @param clientInput one line of client input if any + * @return an event describing new changes in the game state + */ + def clientStep(clientInput: Option[String]): GameEvent = this.readAndParseDataFromServer() - this.displayActions() + val actions = this.getNewActions() - if + val roomState = if this.lastExecutedTurn < this.lastTurnStart && - !this.isSinging + this.lineToSing.isEmpty then - print(this.giveTurn()) - - // TODO: we probably want to quit at EOF - stdinReader.newLine().foreach((s: String) => - this.isSinging = false - output.write(stringToByteArray(s+"\r\n")) + this.giveTurn() + Some(this.turnInfo) + else + None + + for line <- clientInput do + this.lineToSing = None + output.write(stringToByteArray(s"$line\r\n")) + + val timeOfTurnEnd = this.lastTurnStart + this.timeLimit + val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd + GameEvent( + actions, + roomState, + this.lineToSing, + this.canAct, + Some(timeToTurnEnd).filter(p => this.lastTurnStart != 0) ) - end startClient + end clientStep private def readAndParseDataFromServer(): Unit = @@ -116,33 +124,24 @@ class Client(socket: Socket): parseDataFromServer(buffer.take(bytesRead)) availableBytes = input.available() - private def giveTurn(): String = + private def giveTurn(): Unit = this.canAct = true this.lastExecutedTurn = currentTimeMillis / 1000 - s"\n\n${this.turnInfo}\n${this.actionGetterIndicator}" private def bufferAction(action: String): Unit = this.bufferedActions += action - private def displayActions(): Unit = + private def getNewActions(): Option[Vector[String]] = val somethingToShow = this.bufferedActions.nonEmpty - if somethingToShow then - if !this.isSinging then - this.bufferedActions.foreach(println(_)) - this.bufferedActions.clear() - if !this.isSinging && this.canAct && somethingToShow then - print(this.actionGetterIndicator) + if somethingToShow && this.lineToSing.isEmpty then + val res = this.bufferedActions.toVector + this.bufferedActions.clear() + Some(res) + else + None private def startSong(verse: String): Unit = - this.isSinging = true - print(s"\nLaula: “$verse”\n> ") - - // TODO: this is sometimes in front of actions, use an indicator to test if newline before actions? - private def actionGetterIndicator = - val timeOfTurnEnd = this.lastTurnStart + this.timeLimit - val timeToTurnEnd = -currentTimeMillis()/1000 + timeOfTurnEnd - s"[$timeToTurnEnd]> " - + this.lineToSing = Some(verse) private def parseDataFromServer(data: Array[Byte]): Unit = diff --git a/src/scalevalapokalypsi/Client/GameEvent.scala b/src/scalevalapokalypsi/Client/GameEvent.scala new file mode 100644 index 0000000..8aa1e1c --- /dev/null +++ b/src/scalevalapokalypsi/Client/GameEvent.scala @@ -0,0 +1,11 @@ +package scalevalapokalypsi.Client + +class GameEvent( + val actions: Option[Vector[String]], + val roomState: Option[RoomState], + val lineToSing: Option[String], + val playerCanAct: Boolean, + val timeToNextTurn: Option[Long] +) + + diff --git a/src/scalevalapokalypsi/Client/Turn.scala b/src/scalevalapokalypsi/Client/Turn.scala index 30101c5..02fe11b 100644 --- a/src/scalevalapokalypsi/Client/Turn.scala +++ b/src/scalevalapokalypsi/Client/Turn.scala @@ -4,7 +4,7 @@ package scalevalapokalypsi.Client * This class exists essentially so that the client has somewhere * to store data about turns and something to format that data with. */ -class Turn: +class RoomState: /** Description of the area the player controlled by the client is in * at the end of the turn. */ @@ -29,4 +29,4 @@ class Turn: (s"$areaDescription\n$directionDesc\n" + s"\n$itemDesc\n$entityDesc") -end Turn +end RoomState diff --git a/src/scalevalapokalypsi/constants/constants.scala b/src/scalevalapokalypsi/constants/constants.scala index cb08962..7d4e1a6 100644 --- a/src/scalevalapokalypsi/constants/constants.scala +++ b/src/scalevalapokalypsi/constants/constants.scala @@ -9,12 +9,13 @@ val TURN_INDICATOR = ">" val SING_INDICATOR = "~" val ACTION_BLOCKING_INDICATOR='.' val ACTION_NONBLOCKING_INDICATOR='+' +val INITIAL_CONN_TIMEOUT = 5000 // millisec. val LIST_SEPARATOR=";" val PROTOCOL_VERSION_GOOD = "1" val PROTOCOL_VERSION_BAD = "0" -//assert(PROTOCOL_VERSION_BAD.length <= PROTOCOL_VERSION_GOOD.length) +// 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 index 50e89e5..f953751 100644 --- a/src/scalevalapokalypsi/main.scala +++ b/src/scalevalapokalypsi/main.scala @@ -1,7 +1,10 @@ package scalevalapokalypsi -import scalevalapokalypsi.Client.newClient +import scalevalapokalypsi.Client.{newClient, Client, StdinLineReader, GameEvent} import scalevalapokalypsi.Server.Server +import scalevalapokalypsi.constants.* +import java.lang.Thread.sleep +import scala.util.Try import java.lang.Thread import scala.io.StdIn.readLine @@ -15,11 +18,61 @@ import scala.io.StdIn.readLine println("Server started in background.") print("Choose a name:\n> ") val name = readLine() - newClient(name, "127.0.0.1", 2267).foreach(_.startClient()) + Try(newClient(name, "127.0.0.1", 2267)) + .toOption + .flatten match + case Some(client) => + startClient(client) + case None => + println("Starting the client failed.") + case Some(2) => print("Choose a name:\n> ") val name = readLine() - newClient(name, "127.0.0.1", 2267).foreach(_.startClient()) + Try(newClient(name, "127.0.0.1", 2267)) + .toOption + .flatten match + case Some(client) => + startClient(client) + case None => + println("Starting the client failed.") case _ => println("Invalid input") +def startClient(client: Client): Unit = + var hasQuit = false + val stdinReader = StdinLineReader() + stdinReader.startReading() + val printer = Printer() + while !hasQuit do + sleep(POLL_INTERVAL) + val gameEvent = client.clientStep(stdinReader.newLine()) + printer.printGameEvent(gameEvent) + + +class Printer: + var inputIndicatorAtStartOfLine = false + def printGameEvent(gameEvent: GameEvent): Unit = + val actions = gameEvent.actions.map(_.mkString("\n")) + val roomState = gameEvent.roomState.map(_.toString) + val lineToSing = gameEvent.lineToSing + if + inputIndicatorAtStartOfLine && + (actions.isDefined || + roomState.isDefined || + lineToSing.isDefined) + then + this.printLn("") + actions.foreach(this.printLn(_)) + roomState.foreach(this.printLn(_)) + lineToSing.foreach(this.printLn(_)) + val timeLeft = s"${gameEvent.timeToNextTurn.getOrElse("∞")}" + if gameEvent.playerCanAct && !inputIndicatorAtStartOfLine then + this.inputIndicatorAtStartOfLine = true + print(s"[$timeLeft s]> ") + if gameEvent.playerCanAct then + print(s"\u001b[s\u001b[0E[$timeLeft s]\u001b[u") + + private def printLn(s: String): Unit = + println(s) + this.inputIndicatorAtStartOfLine = false -- cgit v1.2.3 From 49985d1d11c426968fc298469671326aace96d00 Mon Sep 17 00:00:00 2001 From: Joel Kronqvist Date: Thu, 21 Nov 2024 23:19:00 +0200 Subject: Fixed singing from last commit --- .gitignore | 1 + src/scalevalapokalypsi/Client/Client.scala | 1 - src/scalevalapokalypsi/Model/Entities/Entity.scala | 5 +- src/scalevalapokalypsi/Model/Entities/Player.scala | 2 - src/scalevalapokalypsi/Server/Client.scala | 58 ++++++++++------------ src/scalevalapokalypsi/Server/Server.scala | 6 ++- src/scalevalapokalypsi/main.scala | 51 ++++++++++++++++--- 7 files changed, 75 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 1fcb152..296d678 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ out +.scala-build diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala index 75ce2e7..0532038 100644 --- a/src/scalevalapokalypsi/Client/Client.scala +++ b/src/scalevalapokalypsi/Client/Client.scala @@ -77,7 +77,6 @@ class Client(socket: Socket): ) private val turnInfo = RoomState() - /** Takes a client step and optionally returns an in-game event for UI * * @param clientInput one line of client input if any diff --git a/src/scalevalapokalypsi/Model/Entities/Entity.scala b/src/scalevalapokalypsi/Model/Entities/Entity.scala index e7cd45c..0427297 100644 --- a/src/scalevalapokalypsi/Model/Entities/Entity.scala +++ b/src/scalevalapokalypsi/Model/Entities/Entity.scala @@ -58,10 +58,7 @@ class Entity( ("Olet täysin kunnossa.", s"$name näyttää kuin vastasyntyneeltä.") /** Does nothing, except possibly in inherited classes. */ - def observeString(observation: String): Unit = - println(" [debug] entity got observation string & discarded it") - def observe(event: Event): Unit = - println(" [debug] entity got observation event & discarded it") + def observe(event: Event): Unit = () /** Returns the player’s current location. */ def location = this.currentLocation diff --git a/src/scalevalapokalypsi/Model/Entities/Player.scala b/src/scalevalapokalypsi/Model/Entities/Player.scala index cac5bf1..a66f521 100644 --- a/src/scalevalapokalypsi/Model/Entities/Player.scala +++ b/src/scalevalapokalypsi/Model/Entities/Player.scala @@ -18,8 +18,6 @@ class Player(name: String, initialLocation: Area) extends Entity(name, initialLo private val observedEvents: Buffer[Event] = Buffer.empty private var pendingSingEffect: Option[SingEffect] = None - override def observeString(observation: String): Unit = - this.observations.append(observation) override def observe(event: Event): Unit = this.observedEvents.append(event) diff --git a/src/scalevalapokalypsi/Server/Client.scala b/src/scalevalapokalypsi/Server/Client.scala index 17c3777..d689356 100644 --- a/src/scalevalapokalypsi/Server/Client.scala +++ b/src/scalevalapokalypsi/Server/Client.scala @@ -20,13 +20,15 @@ class Client(val socket: Socket): private var nextAction: Option[Action] = None private var turnUsed = false private var singStartTime: Option[Long] = None + private var verseToSing: String = "" def clientHasSong = this.singStartTime.isDefined - def startSong(): Unit = + def startSong(verse: String): Unit = + this.verseToSing = verse this.singStartTime = Some(currentTimeMillis() / 1000) /** Calculates the amount of bytes available for future incoming messages */ - def spaceAvailable: Int = MAX_MSG_SIZE - incompleteMessageIndex + def spaceAvailable: Int = this.incompleteMessage.size - incompleteMessageIndex - 1 /** Tests whether the client has behaved according to protocol. * @@ -75,12 +77,13 @@ class Client(val socket: Socket): * @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 + if data.length > this.spaceAvailable then + false + else + for i <- 0 until min(data.length, this.spaceAvailable) do + this.incompleteMessage(this.incompleteMessageIndex+i) = data(i) + this.incompleteMessageIndex += data.length + true /** Returns data that should be sent to this client. * The data is cleared when calling. @@ -109,6 +112,7 @@ class Client(val socket: Socket): val message = this.incompleteMessage.take(nextCRLF) val rest = this.incompleteMessage.drop(nextCRLF + 2) this.incompleteMessage = rest ++ Array.fill(nextCRLF + 1)(0.toByte) + this.incompleteMessageIndex = 0 // TODO: the conversion may probably be exploited to crash the server Some(String(message)) else @@ -158,43 +162,31 @@ class Client(val socket: Socket): /** Buffers the action for execution or executes it immediately if it * doesn't take a turn */ private def executeLine(line: String) = + if !this.turnUsed then this.singStartTime match case Some(t) => + val timePassed = currentTimeMillis()/1000 - t - this.player.foreach(_.applySingEffect( - 1 / max(5, timePassed) * 5 - )) + + val quality = if line == this.verseToSing then + 5.0 / max(5.0, timePassed.toDouble) + else + 0.0 + + this.player.foreach(_.applySingEffect(quality.toFloat)) + this.singStartTime = None + case None => + val action = Action(line) val takesATurn = this.character.exists(p => action.execute(p)) if takesATurn then this.addDataToSend(s"$ACTION_BLOCKING_INDICATOR") this.turnUsed = true - /* - val takesATurn = this.character.exists(action.execute(_)) - if takesATurn then - this.addDataToSend(s"$ACTION_BLOCKING_INDICATOR") - if ( - this.nextAction.isEmpty && - this.player.exists(action.takesATurnFor(_)) - ) then - this.nextAction = Some(action) - else if this.nextAction.isEmpty then - this.singStartTime match - case Some(t) => - val timePassed = currentTimeMillis()/1000 - t - this.player.foreach(_.applySingEffect( - 1 / max(5, timePassed) * 5 - )) - this.singStartTime = None - case None => - this.addDataToSend( - s"$ACTION_NONBLOCKING_INDICATOR${this.executeAction(action)}" - )*/ - + end executeLine end Client diff --git a/src/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala index bfb0893..8debdba 100644 --- a/src/scalevalapokalypsi/Server/Server.scala +++ b/src/scalevalapokalypsi/Server/Server.scala @@ -123,12 +123,14 @@ class Server( val target = c.player.flatMap(_.getSingEffectTarget) target.foreach(t => if c.player.exists(_.isSinging) && !c.clientHasSong then + val verse = t.getVerseAgainst + println(s"got verse against: “$verse”") this.writeToClient( - s"${SING_INDICATOR}${t.getVerseAgainst}\r\n", + s"${SING_INDICATOR}$verse\r\n", // TODO: store the verse and check how close client input is when determining sing quality c ) - c.startSong() + c.startSong(verse) ) ) diff --git a/src/scalevalapokalypsi/main.scala b/src/scalevalapokalypsi/main.scala index f953751..e357845 100644 --- a/src/scalevalapokalypsi/main.scala +++ b/src/scalevalapokalypsi/main.scala @@ -4,6 +4,7 @@ import scalevalapokalypsi.Client.{newClient, Client, StdinLineReader, GameEvent} import scalevalapokalypsi.Server.Server import scalevalapokalypsi.constants.* import java.lang.Thread.sleep +import java.lang.System.currentTimeMillis import scala.util.Try import java.lang.Thread @@ -45,16 +46,27 @@ def startClient(client: Client): Unit = val printer = Printer() while !hasQuit do sleep(POLL_INTERVAL) - val gameEvent = client.clientStep(stdinReader.newLine()) - printer.printGameEvent(gameEvent) + val line = stdinReader.newLine() + if line.map(_.length).getOrElse(0) > 1024 then + printer.printLn("Virhe: Syötteesi oli liian pitkä.") + else if line == Some("quit") then + hasQuit = true + else + val gameEvent = client.clientStep(line) + printer.printGameEvent(gameEvent) class Printer: var inputIndicatorAtStartOfLine = false + var queriedLineToSing = false + var singStartTime: Option[Long] = None + def printGameEvent(gameEvent: GameEvent): Unit = + val actions = gameEvent.actions.map(_.mkString("\n")) val roomState = gameEvent.roomState.map(_.toString) val lineToSing = gameEvent.lineToSing + if inputIndicatorAtStartOfLine && (actions.isDefined || @@ -62,17 +74,42 @@ class Printer: lineToSing.isDefined) then this.printLn("") + actions.foreach(this.printLn(_)) + roomState.foreach(this.printLn(_)) - lineToSing.foreach(this.printLn(_)) + + lineToSing match + case Some(l) => + if this.singStartTime.isEmpty then + this.singStartTime = Some(currentTimeMillis() / 1000) + print(s"Laula: “$l”\n ") + val timeSpent = this.singStartTime.map((t: Long) => + (currentTimeMillis / 1000 - t).toString + ).getOrElse("?") + print(this.timeIndicatorUpdater(timeSpent)) + case None => + this.singStartTime = None + val timeLeft = s"${gameEvent.timeToNextTurn.getOrElse("∞")}" - if gameEvent.playerCanAct && !inputIndicatorAtStartOfLine then + + if + gameEvent.playerCanAct && + lineToSing.isEmpty && + !inputIndicatorAtStartOfLine + then this.inputIndicatorAtStartOfLine = true print(s"[$timeLeft s]> ") - if gameEvent.playerCanAct then - print(s"\u001b[s\u001b[0E[$timeLeft s]\u001b[u") - private def printLn(s: String): Unit = + if gameEvent.playerCanAct && lineToSing.isEmpty then + print(this.timeIndicatorUpdater(timeLeft)) + + end printGameEvent + + def printLn(s: String): Unit = println(s) this.inputIndicatorAtStartOfLine = false + + private def timeIndicatorUpdater(t: String): String = + s"\u001b[s\u001b[0E[$t s]> \u001b[u" -- cgit v1.2.3 From db5612ed9734d51e6fcd0d7b5a7635e49b773581 Mon Sep 17 00:00:00 2001 From: Joel Kronqvist Date: Fri, 22 Nov 2024 22:42:22 +0200 Subject: Character safety checking, supported terminals updated --- README.txt | 95 +++++++++++++-- scalevalapokalypsi.iml | 1 + src/scalevalapokalypsi/Client/Client.scala | 3 +- .../Client/ReceivedLineParser.scala | 7 +- src/scalevalapokalypsi/Client/RoomState.scala | 32 +++++ .../Client/StdinLineReader.scala | 31 ----- src/scalevalapokalypsi/Client/Turn.scala | 32 ----- src/scalevalapokalypsi/Model/Action.scala | 5 +- src/scalevalapokalypsi/Model/Area.scala | 27 ----- src/scalevalapokalypsi/Server/Client.scala | 10 +- src/scalevalapokalypsi/Server/Server.scala | 7 +- src/scalevalapokalypsi/UI/.bsp/scala.json | 45 +++++++ src/scalevalapokalypsi/UI/Printer.scala | 78 ++++++++++++ src/scalevalapokalypsi/UI/StdinLineReader.scala | 33 ++++++ src/scalevalapokalypsi/UI/main.scala | 132 +++++++++++++++++++++ src/scalevalapokalypsi/constants/constants.scala | 8 ++ src/scalevalapokalypsi/main.scala | 115 ------------------ src/scalevalapokalypsi/utils/utils.scala | 45 ++++++- 18 files changed, 477 insertions(+), 229 deletions(-) create mode 100644 src/scalevalapokalypsi/Client/RoomState.scala delete mode 100644 src/scalevalapokalypsi/Client/StdinLineReader.scala delete mode 100644 src/scalevalapokalypsi/Client/Turn.scala create mode 100644 src/scalevalapokalypsi/UI/.bsp/scala.json create mode 100644 src/scalevalapokalypsi/UI/Printer.scala create mode 100644 src/scalevalapokalypsi/UI/StdinLineReader.scala create mode 100644 src/scalevalapokalypsi/UI/main.scala delete mode 100644 src/scalevalapokalypsi/main.scala diff --git a/README.txt b/README.txt index 5f4f277..4ada829 100644 --- a/README.txt +++ b/README.txt @@ -23,18 +23,99 @@ ne olennaisesti tehdä mitä tahansa, sen sijaan että Z-kone millään lailla rajoittaisi niitä. +Järjestelmävaatimukset (TÄRKEÄÄ) +-------------------------------- + +INTELLIJ IDEA:n OLETUSTERMINAALI EI OLE TUETTU!!! Sinun on pelattava tätä +jossain tuetussa terminaalissa. Muuten tuloste tulee näyttämään oudolta +(todennäköisesti rivin alku tulostuu toistuvasti siihen, mihin yrität +kirjoittaa tai kursorisi ei pääse pois rivin alusta). Onneksi tuetut +terminaalit ovat varsin yleisiä: nopealla testillä kaikki keksityt +terminaalit olivat tuettuja IDEAa lukuun ottamatta. + +Tuettuja olivat ainakin: +* cmd.exe (dokumentaation perusteella - ei ollut saatavilla testiin) +* alacritty (toimii myös Windowsilla) +* xterm +* gnome-terminal +* konsole +* kitty +* xfce4-terminal +* jopa st (miten sekin on OOB parempi kuin IDEA?) + +Tarkemmin määriteltynä, jos uuden terminaalin lataamista ennen tahdot varmistua +kyseisen terminaalin sopivuudesta: terminaalisi on tuettava ANSI-koodeja `ESC7`, +`ESC8` ja `ESC[0E` (jossa 0 voisi muissa sovelluksissa olla jokin muukin luku). +Näiden koodien bitit ovat samassa järjestyksessä 0x001b 0x0037, 0x001b 0x0038 ja +0x001b 0x005b 0x0045, mikäli edelliset merkinnät olivat epäselvät. + + Pelin käynnistäminen -------------------- -[yksittäisen käyttäjän käynnistäminen] +Saat käynnistettyä pelin seuraavasti: +``` +1) Liity viralliselle palvelimelle (cron4.fi) +2) Käynnistä palvelin laitteellasi ja liity sille +3) Liity mielivaltaiselle palvelimelle +> 1 +Valitse itsellesi pelin sisäinen alter ego. +> [nimi] +[liityt peliin] +``` +Huomaa, että olet liittynyt julkiselle palvelimelle, jolla saattaa olla muita +pelaajia. Jos olet assari arvioimassa, kovin moni tuskin vielä tietää pelistä +taikka palvelimesta, joten pelin tila lienee jotakuinkin ennallaan. Moninpeliominaisuudesta saat eniten iloa irti, joten jaa [pelin nimi] -ystävillesi ja ala pelaamaan! Jos sinulla ei ole ystäviä, voit myös kokea -moninpeliominaisuuden mahtavuuden hieman vaisumpana käynnistämällä toisen -käyttäjän ylläolevien ohjeiden mukaisesti ja liittymällä samaan peliin -(eli samalle serverille), johon edellisellä käyttäjällä liityit. Toista -tämä niin monta kertaa kuin tahdot, kunnes serveri valittaa liian suuresta -pelaajamäärästä. +ystävillesi ja ala pelaamaan! Jos sinulla ei ole ystäviä, onnistuu moninpeli- +ominaisuuden testaaminen joko liittymällä useamman kerran julkiselle palveli- +melle tai pystyttämällä palvelin omalle koneelle seuraavasti `n` pelaajaa +varten. + +Prosessi 1: +``` +Miten tahdot pelata? +1) Liity viralliselle palvelimelle (cron4.fi) +2) Käynnistä palvelin laitteellasi ja liity sille +3) Liity mielivaltaiselle palvelimelle +> 2 +Valitse portti palvelimelle. (Jätä tyhjäksi oletusta 2267 varten) +> +Kuinka monta pelaajaa pelissä saa olla samanaikaisesti? +> [n] +Syötä vuorojen aikaraja yksiköttömänä sekunneissa odottelun vähentämiseksi. Syötä 0 aikarajan poistamiseksi. (Jätä tyhjäksi oletusta 30 varten) +> +Palvelin käynnistetty taustalla. +Valitse itsellesi pelin sisäinen alter ego. +> [nimi] +[liityt peliin] +``` + +n x muu prosessi: (huom. en ole prosessiteekkari) +``` +Miten tahdot pelata? +1) Liity viralliselle palvelimelle (cron4.fi) +2) Käynnistä palvelin laitteellasi ja liity sille +3) Liity mielivaltaiselle palvelimelle +> 3 +Syötä palvelimen verkkotunnus. +> 127.0.0.1 +Valitse portti palvelimelle. (Jätä tyhjäksi oletusta 2267 varten) +> +Valitse itsellesi pelin sisäinen alter ego. +> Bob +``` + +Huomaa, että pelin ajamista varten samalla koneella useampaan kertaan, on +IntelliJ IDEA:n ajokonfiguraatiota muokattava. Olettaen, että ohjelma on kerran +ajettu main-funktion play-napista, valitse play-napin vierestä valikko, jossa +lukee `main` tai `[pelin nimi]` -jotain. Sieltä valitse "Edit Configurations". +Valitse oikea kohde avautuvan ikkunan vasemmasta palkista, klikkaa kohtaa +"Modify options" ja valitse "Allow multiple instances". Sitten OK/Apply. +Jos tästä muodostuu ongelma, kannattaa ensin vähän googlata tai lukea seuraava +StackOverflow: +=> https://stackoverflow.com/questions/41226555/how-do-i-run-the-same-application-twice-in-intellij Pelin tavoite diff --git a/scalevalapokalypsi.iml b/scalevalapokalypsi.iml index a76f8e7..c38826d 100644 --- a/scalevalapokalypsi.iml +++ b/scalevalapokalypsi.iml @@ -8,5 +8,6 @@ + \ No newline at end of file diff --git a/src/scalevalapokalypsi/Client/Client.scala b/src/scalevalapokalypsi/Client/Client.scala index 0532038..e94fdb0 100644 --- a/src/scalevalapokalypsi/Client/Client.scala +++ b/src/scalevalapokalypsi/Client/Client.scala @@ -5,7 +5,7 @@ import scala.io.Source import scala.sys.process.stdout import scalevalapokalypsi.constants.* import scalevalapokalypsi.utils.{stringToByteArray,getNCharsFromSocket} -import scalevalapokalypsi.Client.{ReceivedLineParser,StdinLineReader,RoomState} +import scalevalapokalypsi.Client.{ReceivedLineParser,RoomState} import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global import scala.util.{Try, Success, Failure} @@ -119,7 +119,6 @@ class Client(socket: Socket): 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() diff --git a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala index 9337ce1..bccba59 100644 --- a/src/scalevalapokalypsi/Client/ReceivedLineParser.scala +++ b/src/scalevalapokalypsi/Client/ReceivedLineParser.scala @@ -2,6 +2,7 @@ package scalevalapokalypsi.Client import scala.collection.mutable.Buffer import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.* /** A class for checking asynchronously for received lines */ class ReceivedLineParser: @@ -10,11 +11,11 @@ class ReceivedLineParser: private var bufferedData: Buffer[Byte] = Buffer.empty // TODO: suboptimal DS - /** Add received data */ + /** Add received data */ def in(data: Array[Byte]): Unit = this.bufferedData ++= data - /** Read a line from the received data */ + /** Read a line from the received data */ def nextLine(): Option[String] = val indexOfCRLF = this.bufferedData.indexOfSlice(CRLF) if indexOfCRLF == -1 then @@ -22,6 +23,6 @@ class ReceivedLineParser: else val splitData = this.bufferedData.splitAt(indexOfCRLF) this.bufferedData = Buffer.from(splitData(1).drop(CRLF.length)) - Some(String(splitData(0).toArray)) + byteArrayToString(splitData(0).toArray) end ReceivedLineParser diff --git a/src/scalevalapokalypsi/Client/RoomState.scala b/src/scalevalapokalypsi/Client/RoomState.scala new file mode 100644 index 0000000..02fe11b --- /dev/null +++ b/src/scalevalapokalypsi/Client/RoomState.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 RoomState: + + /** 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 RoomState diff --git a/src/scalevalapokalypsi/Client/StdinLineReader.scala b/src/scalevalapokalypsi/Client/StdinLineReader.scala deleted file mode 100644 index 6ba8761..0000000 --- a/src/scalevalapokalypsi/Client/StdinLineReader.scala +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 02fe11b..0000000 --- a/src/scalevalapokalypsi/Client/Turn.scala +++ /dev/null @@ -1,32 +0,0 @@ -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 RoomState: - - /** 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 RoomState diff --git a/src/scalevalapokalypsi/Model/Action.scala b/src/scalevalapokalypsi/Model/Action.scala index a781ee8..fdfbf75 100644 --- a/src/scalevalapokalypsi/Model/Action.scala +++ b/src/scalevalapokalypsi/Model/Action.scala @@ -29,7 +29,10 @@ class Action(input: String): val resOption: Option[(Boolean, Event)] = this.verb match case "go" => val result = actor.go(this.modifiers) - result.foreach(r => oldLocation.observeEvent(r)) + result.foreach(r => + if actor.location != oldLocation then + oldLocation.observeEvent(r) + ) result.map((true, _)) case "rest" => Some((true, actor.rest())) case "get" => Some((false, actor.pickUp(this.modifiers))) diff --git a/src/scalevalapokalypsi/Model/Area.scala b/src/scalevalapokalypsi/Model/Area.scala index 96392ba..c07f2f9 100644 --- a/src/scalevalapokalypsi/Model/Area.scala +++ b/src/scalevalapokalypsi/Model/Area.scala @@ -103,33 +103,6 @@ class Area(val name: String, var description: String): 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) diff --git a/src/scalevalapokalypsi/Server/Client.scala b/src/scalevalapokalypsi/Server/Client.scala index d689356..5727fc6 100644 --- a/src/scalevalapokalypsi/Server/Client.scala +++ b/src/scalevalapokalypsi/Server/Client.scala @@ -3,6 +3,7 @@ package scalevalapokalypsi.Server import java.net.Socket import scala.math.{min,max} import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.* import ServerProtocolState.* import scalevalapokalypsi.Model.Action import scalevalapokalypsi.Model.Entities.Player @@ -113,8 +114,7 @@ class Client(val socket: Socket): val rest = this.incompleteMessage.drop(nextCRLF + 2) this.incompleteMessage = rest ++ Array.fill(nextCRLF + 1)(0.toByte) this.incompleteMessageIndex = 0 - // TODO: the conversion may probably be exploited to crash the server - Some(String(message)) + byteArrayToString(message) else None @@ -178,7 +178,7 @@ class Client(val socket: Socket): this.singStartTime = None - case None => + case None if isPrintable(line) => val action = Action(line) val takesATurn = this.character.exists(p => action.execute(p)) @@ -186,6 +186,10 @@ class Client(val socket: Socket): this.addDataToSend(s"$ACTION_BLOCKING_INDICATOR") this.turnUsed = true + case None => + + () // There were some illegal chars but whatever + end executeLine end Client diff --git a/src/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala index 8debdba..89db302 100644 --- a/src/scalevalapokalypsi/Server/Server.scala +++ b/src/scalevalapokalypsi/Server/Server.scala @@ -124,12 +124,7 @@ class Server( target.foreach(t => if c.player.exists(_.isSinging) && !c.clientHasSong then val verse = t.getVerseAgainst - println(s"got verse against: “$verse”") - this.writeToClient( - s"${SING_INDICATOR}$verse\r\n", - // TODO: store the verse and check how close client input is when determining sing quality - c - ) + this.writeToClient(s"${SING_INDICATOR}$verse\r\n", c) c.startSong(verse) ) ) diff --git a/src/scalevalapokalypsi/UI/.bsp/scala.json b/src/scalevalapokalypsi/UI/.bsp/scala.json new file mode 100644 index 0000000..fe879ce --- /dev/null +++ b/src/scalevalapokalypsi/UI/.bsp/scala.json @@ -0,0 +1,45 @@ +{ + "name": "scala", + "argv": [ + "/home/cron4/.cache/coursier/arc/https/github.com/scala/scala3/releases/download/3.5.0/scala3-3.5.0-x86_64-pc-linux.tar.gz/scala3-3.5.0-x86_64-pc-linux/bin/scala-cli", + "--cli-default-scala-version", + "3.5.0", + "--repository", + "file:///home/cron4/.cache/coursier/arc/https/github.com/scala/scala3/releases/download/3.5.0/scala3-3.5.0-x86_64-pc-linux.tar.gz/scala3-3.5.0-x86_64-pc-linux/maven2", + "--prog-name", + "scala", + "bsp", + "--json-options", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-options-v2.json", + "--json-launcher-options", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-launcher-options.json", + "--envs-file", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/.scala-build/ide-envs.json", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/main.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/UI/Printer.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/Client.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/GameEvent.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/ReceivedLineParser.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/RoomState.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Client/StdinLineReader.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Action.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Adventure.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Area.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Entities", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Event.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/Item.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Model/SingEffects.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Client.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Clients.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/ConnectionGetter.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/Server/Server.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/constants/constants.scala", + "/home/cron4/Dev/scalevalapokalypsi/src/scalevalapokalypsi/utils/utils.scala" + ], + "version": "1.4.0", + "bspVersion": "2.1.1", + "languages": [ + "scala", + "java" + ] +} \ No newline at end of file diff --git a/src/scalevalapokalypsi/UI/Printer.scala b/src/scalevalapokalypsi/UI/Printer.scala new file mode 100644 index 0000000..a33864f --- /dev/null +++ b/src/scalevalapokalypsi/UI/Printer.scala @@ -0,0 +1,78 @@ +package scalevalapokalypsi.UI +import scalevalapokalypsi.Client.GameEvent +import java.lang.System.currentTimeMillis +import scalevalapokalypsi.utils.isPrintable + +/** A singleton for printing. Keeps track about the "action query" at the start + * of lines and has a helper function "pryntGameEvent". + */ +object Printer: + var inputIndicatorAtStartOfLine = false + var queriedLineToSing = false + var singStartTime: Option[Long] = None + + /** Prints the given game event. + * + * @param gameEvent the event to print + */ + def printGameEvent(gameEvent: GameEvent): Unit = + + val actions = gameEvent.actions.map(_.mkString("\n")) + val roomState = gameEvent.roomState.map(_.toString) + val lineToSing = gameEvent.lineToSing + + if + inputIndicatorAtStartOfLine && + (actions.isDefined || + roomState.isDefined || + lineToSing.isDefined) + then + this.printLn("") + + actions.foreach(this.printLn(_)) + + roomState.foreach(this.printLn(_)) + + lineToSing match + case Some(l) => + if this.singStartTime.isEmpty then + this.singStartTime = Some(currentTimeMillis() / 1000) + print(s"Laula: “$l”\n ") + val timeSpent = this.singStartTime.map((t: Long) => + (currentTimeMillis / 1000 - t).toString + ).getOrElse("?") + print(this.timeIndicatorUpdater(timeSpent)) + case None => + this.singStartTime = None + + val timeLeft = s"${gameEvent.timeToNextTurn.getOrElse("∞")}" + + if + gameEvent.playerCanAct && + lineToSing.isEmpty && + !inputIndicatorAtStartOfLine + then + this.inputIndicatorAtStartOfLine = true + print(s"[$timeLeft s]> ") + + if gameEvent.playerCanAct && lineToSing.isEmpty then + print(this.timeIndicatorUpdater(timeLeft)) + + end printGameEvent + + /** Prints the given string with a trailing newline added. Should be used + * instead of ordinary println because printing outside of the Printer + * might cause weird-looking output. + * + * @param s the line to print + */ + def printLn(s: String): Unit = + if isPrintable(s) then + println(s) + else + println("Virhe: epätavallinen merkki havaittu tulosteessa.") + this.inputIndicatorAtStartOfLine = false + + private def timeIndicatorUpdater(t: String): String = + s"\u001b7\u001b[0E[$t s]> \u001b8" + diff --git a/src/scalevalapokalypsi/UI/StdinLineReader.scala b/src/scalevalapokalypsi/UI/StdinLineReader.scala new file mode 100644 index 0000000..4d0f778 --- /dev/null +++ b/src/scalevalapokalypsi/UI/StdinLineReader.scala @@ -0,0 +1,33 @@ +package scalevalapokalypsi.UI + +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)) => + if s.contains("\u0000") then + println("End of stream!") + 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/UI/main.scala b/src/scalevalapokalypsi/UI/main.scala new file mode 100644 index 0000000..44ca0e4 --- /dev/null +++ b/src/scalevalapokalypsi/UI/main.scala @@ -0,0 +1,132 @@ +package scalevalapokalypsi.UI + +import scalevalapokalypsi.Client.{newClient, Client, GameEvent} +import scalevalapokalypsi.Server.Server +import scalevalapokalypsi.constants.* +import scalevalapokalypsi.utils.* +import java.lang.Thread.sleep +import scala.util.{Try,Success,Failure} +import scala.collection.immutable.LazyList + +import java.lang.Thread +import scala.io.StdIn.readLine + + +@main def main(): Unit = + + val option = getValidInput[Int]( + "Miten tahdot pelata?\n" + + s"1) Liity viralliselle palvelimelle ($DEFAULT_SERVER)\n" + + "2) Käynnistä palvelin laitteellasi ja liity sille\n" + + "3) Liity mielivaltaiselle palvelimelle", + s => s.toIntOption match + case None => + Left("Syötä kokonaisluku.") + case Some(i) => + if i < 1 || i > 3 then + Left("Syötä kokonaisluku väliltä [1,3].") + else + Right(i) + ) + + + val serverName = + if option == 3 then + readLine("Syötä palvelimen verkkotunnus.\n> ") + else if option == 2 then + "127.0.0.1" + else + DEFAULT_SERVER + + val serverPort = + if option == 1 then + DEFAULT_PORT + else + getValidInput[Int]( + s"Valitse portti palvelimelle. (Jätä tyhjäksi oletusta $DEFAULT_PORT varten)", + s => + if s == "" then + Right(DEFAULT_PORT) + else + s.toIntOption match + case None => + Left("Syötä kokonaisluku.") + case Some(i) => + if i > 0 && i <= 65535 then + Right(i) + else + Left("Syötä portti lailliselta väliltä.") + ) + + val maxClients = if option == 2 then + getValidInput[Int]( + "Kuinka monta pelaajaa pelissä saa olla samanaikaisesti?", + s => s.toIntOption match + case None => + Left("Syötä kokonaisluku.") + case Some(i) => + if i > 1000 then + println( + "Aika ison määrän valitsit, mutta olkoon menneeksi." + ) + if i > 0 then + Right(i) + else + Left("Syötä positiivinen kokonaisluku.") + ) + else + 0 + + val timeLimit = if option == 2 then + getValidInput[Int]( + s"Syötä vuorojen aikaraja yksiköttömänä sekunneissa odottelun vähentämiseksi. Syötä 0 aikarajan poistamiseksi. (Jätä tyhjäksi oletusta $DEFAULT_TURN_TIME_LIMIT varten)", + s => if s == "" then + Right(DEFAULT_TURN_TIME_LIMIT) + else + s.toIntOption + .toRight("Syötä kokonaisluku") + .filterOrElse( + _ >= 0, + "Syötä epänegatiivinen kokonaisluku." + ) + ) + else + 0 + + if option == 2 then + Thread(() => new Server(serverPort, maxClients, timeLimit, true).startServer()).start() + println("Palvelin käynnistetty taustalla.") + + val name = readLine("Valitse itsellesi pelin sisäinen alter ego.\n> ") + + Try(newClient(name, serverName, serverPort)) + .toOption + .flatten match + case Some(client) => + startClient(client) + case None => + println( + "Serverille liittyminen epäonnistui. Tarkista internet-yhteytesi. Jos yhteytesi on kunnossa ja liittyminen ei pian onnistu, ota yhteyttä Joel Kronqvistiin olettaen, ettei vuodesta 2024 ole kulunut kohtuuttomasti aikaa." + ) + + + +/** Client game loop. Handles output to and from the client in the eyes of the + * terminal. + */ +def startClient(client: Client): Unit = + var hasQuit = false + val stdinReader = StdinLineReader() + stdinReader.startReading() + while !hasQuit do + sleep(POLL_INTERVAL) + val line = stdinReader.newLine() + if line.map(_.length).getOrElse(0) > 1024 then + Printer.printLn("Virhe: Syötteesi oli liian pitkä.") + else if line == Some("quit") then + hasQuit = true + else + val gameEvent = client.clientStep(line) + Printer.printGameEvent(gameEvent) + + diff --git a/src/scalevalapokalypsi/constants/constants.scala b/src/scalevalapokalypsi/constants/constants.scala index 7d4e1a6..970d6f7 100644 --- a/src/scalevalapokalypsi/constants/constants.scala +++ b/src/scalevalapokalypsi/constants/constants.scala @@ -10,9 +10,17 @@ val SING_INDICATOR = "~" val ACTION_BLOCKING_INDICATOR='.' val ACTION_NONBLOCKING_INDICATOR='+' val INITIAL_CONN_TIMEOUT = 5000 // millisec. +val DEFAULT_PORT: Int = 2267 +val DEFAULT_TURN_TIME_LIMIT = 30 +val DEFAULT_SERVER = "cron4.fi" val LIST_SEPARATOR=";" +val BYTE_ALLOWED = + (n: Byte) => !(n <= 31 || (n >= 127 && n <= 159)) + +val FORBIDDEN_CHARACTERS = "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000B\u000C\u000D\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F\u007F\u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008A\u008B\u008C\u008D\u008E\u008F\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009A\u009B\u009C\u009D\u009E\u009F" + val PROTOCOL_VERSION_GOOD = "1" val PROTOCOL_VERSION_BAD = "0" // assert(PROTOCOL_VERSION_BAD.length <= PROTOCOL_VERSION_GOOD.length) diff --git a/src/scalevalapokalypsi/main.scala b/src/scalevalapokalypsi/main.scala deleted file mode 100644 index e357845..0000000 --- a/src/scalevalapokalypsi/main.scala +++ /dev/null @@ -1,115 +0,0 @@ -package scalevalapokalypsi - -import scalevalapokalypsi.Client.{newClient, Client, StdinLineReader, GameEvent} -import scalevalapokalypsi.Server.Server -import scalevalapokalypsi.constants.* -import java.lang.Thread.sleep -import java.lang.System.currentTimeMillis -import scala.util.Try - -import java.lang.Thread -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() - Try(newClient(name, "127.0.0.1", 2267)) - .toOption - .flatten match - case Some(client) => - startClient(client) - case None => - println("Starting the client failed.") - - case Some(2) => - print("Choose a name:\n> ") - val name = readLine() - Try(newClient(name, "127.0.0.1", 2267)) - .toOption - .flatten match - case Some(client) => - startClient(client) - case None => - println("Starting the client failed.") - case _ => println("Invalid input") - -def startClient(client: Client): Unit = - var hasQuit = false - val stdinReader = StdinLineReader() - stdinReader.startReading() - val printer = Printer() - while !hasQuit do - sleep(POLL_INTERVAL) - val line = stdinReader.newLine() - if line.map(_.length).getOrElse(0) > 1024 then - printer.printLn("Virhe: Syötteesi oli liian pitkä.") - else if line == Some("quit") then - hasQuit = true - else - val gameEvent = client.clientStep(line) - printer.printGameEvent(gameEvent) - - -class Printer: - var inputIndicatorAtStartOfLine = false - var queriedLineToSing = false - var singStartTime: Option[Long] = None - - def printGameEvent(gameEvent: GameEvent): Unit = - - val actions = gameEvent.actions.map(_.mkString("\n")) - val roomState = gameEvent.roomState.map(_.toString) - val lineToSing = gameEvent.lineToSing - - if - inputIndicatorAtStartOfLine && - (actions.isDefined || - roomState.isDefined || - lineToSing.isDefined) - then - this.printLn("") - - actions.foreach(this.printLn(_)) - - roomState.foreach(this.printLn(_)) - - lineToSing match - case Some(l) => - if this.singStartTime.isEmpty then - this.singStartTime = Some(currentTimeMillis() / 1000) - print(s"Laula: “$l”\n ") - val timeSpent = this.singStartTime.map((t: Long) => - (currentTimeMillis / 1000 - t).toString - ).getOrElse("?") - print(this.timeIndicatorUpdater(timeSpent)) - case None => - this.singStartTime = None - - val timeLeft = s"${gameEvent.timeToNextTurn.getOrElse("∞")}" - - if - gameEvent.playerCanAct && - lineToSing.isEmpty && - !inputIndicatorAtStartOfLine - then - this.inputIndicatorAtStartOfLine = true - print(s"[$timeLeft s]> ") - - if gameEvent.playerCanAct && lineToSing.isEmpty then - print(this.timeIndicatorUpdater(timeLeft)) - - end printGameEvent - - def printLn(s: String): Unit = - println(s) - this.inputIndicatorAtStartOfLine = false - - private def timeIndicatorUpdater(t: String): String = - s"\u001b[s\u001b[0E[$t s]> \u001b[u" - diff --git a/src/scalevalapokalypsi/utils/utils.scala b/src/scalevalapokalypsi/utils/utils.scala index ab262ad..54d407b 100644 --- a/src/scalevalapokalypsi/utils/utils.scala +++ b/src/scalevalapokalypsi/utils/utils.scala @@ -2,6 +2,9 @@ package scalevalapokalypsi.utils import java.io.InputStream import java.nio.charset.StandardCharsets +import scala.util.Try +import scalevalapokalypsi.constants.* +import scala.io.StdIn.readLine /** Converts this string to an array of bytes (probably for transmission). * @@ -11,6 +14,16 @@ import java.nio.charset.StandardCharsets def stringToByteArray(str: String): Array[Byte] = str.getBytes(StandardCharsets.UTF_8) +/** Converts the given byte array to a string if possible*. + * (* Doesn't convert strings with control sequences in them) + * + * @param bytes the byte array to convert + * @return the matching string, if possible*. + */ +def byteArrayToString(bytes: Array[Byte]): Option[String] = + Try(String(bytes, StandardCharsets.UTF_8)) + .toOption + /** Reads n characters from the given InputStream blockingly. * * @param input the InputStream to read from @@ -25,5 +38,33 @@ def getNCharsFromSocket(input: InputStream, n: Int): Option[String] = 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, StandardCharsets.UTF_8)) + if failed then None else byteArrayToString(buffer) + +def isPrintable(s: String): Boolean = + FORBIDDEN_CHARACTERS.forall((c: Char) => !(s contains c)) + + +/** Gets input from STDIN until the line entered is appoved + * by a validator function. + * + * @param message A query to represent the user with when requesting input + * @param validator A validator function. Should return Right[A] when the + * input is valid and Left[String] when the input is invalid, + * where the string will be printed to the user to notify + * their input was invalid. + * @return The first encountered valid string, see `validator`. + */ +def getValidInput[A]( + message: String, + validator: String => Either[String, A] +): A = + LazyList.continually(readLine(s"$message\n> ")) + .flatMap(input => + validator(input) match + case Left(s) => + println(s) + None + case Right(a) => + Some(a) + ).head + -- cgit v1.2.3 From 28b83db50f33cb704311ffe608dcd8c4412635cf Mon Sep 17 00:00:00 2001 From: Joel Kronqvist Date: Sat, 23 Nov 2024 05:24:10 +0200 Subject: NPCs, zombies and talking --- src/scalevalapokalypsi/Model/Action.scala | 65 +++++++++++++--- src/scalevalapokalypsi/Model/Adventure.scala | 23 +++++- .../Model/Entities/NPCs/NPC.scala | 89 ++++++++++++++++++++++ src/scalevalapokalypsi/Model/Entities/Player.scala | 2 +- src/scalevalapokalypsi/Server/Server.scala | 3 +- 5 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala diff --git a/src/scalevalapokalypsi/Model/Action.scala b/src/scalevalapokalypsi/Model/Action.scala index fdfbf75..287b008 100644 --- a/src/scalevalapokalypsi/Model/Action.scala +++ b/src/scalevalapokalypsi/Model/Action.scala @@ -1,6 +1,7 @@ package scalevalapokalypsi.Model import scalevalapokalypsi.Model.Entities.* +import scalevalapokalypsi.Model.Entities.NPCs.* /** The class `Action` represents actions that a player may take in a text * adventure game. `Action` objects are constructed on the basis of textual @@ -36,20 +37,60 @@ class Action(input: String): result.map((true, _)) case "rest" => Some((true, actor.rest())) case "get" => Some((false, 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 + case "sano" => + val entityNames = actor.location.getEntityNames.map(_.toLowerCase) + val recipientNamePair = entityNames.map(name => + val possibleNamesWithSuffix = (0 to "ille".length).map(i => + modifiers.takeRight(name.length + i) + ) + possibleNamesWithSuffix.find(s => + s.take(name.length) == name + ) + .map(_.splitAt(name.length)) + ).flatten.headOption + + val recipient = recipientNamePair.flatMap(p => + actor.location.getEntity(p(0)) ) - val message = - modifiers.take(modifiers.length - recipient.length - 4) - if maybeTo == to then - recipientEntity.map(e => (false, actor.sayTo(e, message))) - else + + val message = recipientNamePair + .map(p => modifiers.dropRight(p(0).length + p(1).length)) + .filter(_.takeRight(1) == " ") + .map(_.dropRight(1)) + + message.map(m => + recipient.map(e => (false, actor.sayTo(e, m))) + ).getOrElse( Some((false, actor.say(modifiers))) + ) + case "puhu" => + val recipient = modifiers + .indices.take("ille".length + 1) + .map(i => modifiers.take(modifiers.length - i)) + .find(name => actor.location.getEntity(name).isDefined) + .flatMap(name => actor.location.getEntity(name)) + val dialog = recipient match + case Some(npc: NPC) => + s"${npc.name}: ”${npc.getDialog}”" + case Some(player: Player) => + "Et voi puhua pelaajille, vain sanoa asioita heille." + case Some(other) => + "Et voi puhua tälle olennolle." + case None => + "Kyseistä puhujaa ei löytynyt." + + val fromThirdPerson = recipient + .filter(a => a.isInstanceOf[NPC]) + .map(a => s"${actor.name} puhuu $modifiers") + + Some( + ( + false, + Event(Vector(( + actor, dialog + )).toMap, fromThirdPerson.getOrElse("")) + ) + ) case "drop" => Some((false, actor.drop(this.modifiers))) case "laula" => val end = modifiers.takeRight("suohon".length) diff --git a/src/scalevalapokalypsi/Model/Adventure.scala b/src/scalevalapokalypsi/Model/Adventure.scala index 9347bfa..09eed54 100644 --- a/src/scalevalapokalypsi/Model/Adventure.scala +++ b/src/scalevalapokalypsi/Model/Adventure.scala @@ -2,6 +2,7 @@ package scalevalapokalypsi.Model import scala.collection.mutable.Map import scalevalapokalypsi.Model.Entities.* +import scalevalapokalypsi.Model.Entities.NPCs.* /** The class `Adventure` holds data of the game world and provides methods * for implementing a user interface for it. @@ -20,7 +21,6 @@ class Adventure(val playerNames: Vector[String]): 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)) @@ -37,6 +37,23 @@ class Adventure(val playerNames: Vector[String]): )) val entities: Map[String, Entity] = Map() + + val npcs: Map[String, NPC] = Map() + + private val zombieAttrs = Vector( + ("Weary zombie", clearing, 20), + ("Smelly zombie", home, 20), + ("Rotten zombie", tangle, 10) + ) + zombieAttrs.foreach(z => + val zombie = Zombie(z(0), z(1), z(2)) + npcs += z(0) -> zombie + z(1).addEntity(zombie) + ) + + def takeNpcTurns(): Unit = + npcs.values.foreach(_.act()) + private val gruu = Entity("Gruu", northForest) northForest.addEntity(gruu) this.entities += gruu.name -> gruu @@ -64,7 +81,9 @@ class Adventure(val playerNames: Vector[String]): def getPlayer(name: String): Option[Player] = this.players.get(name) def getEntity[A >: Entity](name: String) = - this.players.getOrElse(name, this.entities.get(name)) + this.players.get(name) + .orElse(this.npcs.get(name)) + .getOrElse(this.entities.get(name)) end Adventure diff --git a/src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala b/src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala new file mode 100644 index 0000000..21709ba --- /dev/null +++ b/src/scalevalapokalypsi/Model/Entities/NPCs/NPC.scala @@ -0,0 +1,89 @@ + +package scalevalapokalypsi.Model.Entities.NPCs + +import scala.collection.mutable.Buffer +import scalevalapokalypsi.Model.* +import scalevalapokalypsi.Model.Entities.* +import scala.util.Random + +/** A `NPC` object represents a non-playable in-game character controlled by + * the server using this objects `act` method. It can also be "talked to": it + * returns a dialog when asked for. + * + * A NPC object’s state is mutable: the NPC’s location and possessions can change, + * for instance. + * + * @param name the NPC's name + * @param initialLocation the NPC’s initial location + */ +abstract class NPC( + name: String, + initialLocation: Area, + initialHP: Int, + maxHp: Int +) extends Entity(name, initialLocation, initialHP, maxHp): + def getDialog: String + def act(): Unit + +class Zombie( + identifier: String, + initialLocation: Area, + initialHP: Int = 20 +) extends NPC(identifier, initialLocation, initialHP, 20): + + private val damage = 10 + private val dialogs = Vector( + "örvlg", + "grr", + "äyyrrrgrlgb ww", + "aaak brzzzwff ååö", + "äkb glan abglum", + "öub gpa" + ) + + override def getDialog: String = + val dialogIndex = Random.between(0, this.dialogs.length) + this.dialogs(dialogIndex) + + override def act(): Unit = + val possibleVictims = this.location + .getEntities + .filter(_ != this) + .toVector + val index: Int = + if possibleVictims.isEmpty then 0 + else Random.between(0, possibleVictims.length) + if possibleVictims.isEmpty then + val possibleDirections = this.location.getNeighborNames.toVector + val directionIndex = Random.between(0, possibleDirections.length*2) + possibleDirections + .toVector + .lift(directionIndex) + .flatMap(this.go(_)) + .map(this.location.observeEvent(_)) + else + this.location.observeEvent( + this.attack(possibleVictims(index)) + ) + + + private def attack(entity: Entity): Event = + if Random.nextBoolean() then + entity.takeDamage(this.damage) + Event( + Map.from(Vector(( + entity, + s"${this.name} puree sinua, hyi yäk!\n" + + s"${entity.condition(0)}" + ))), + s"${this.name} puree henkilöä ${entity.name}.\n" + + s"${entity.condition(1)}" + ) + else + Event( + Map.from(Vector(( + entity, + s"${this.name} yrittää purra sinua mutta kaatuu ohitsesi." + ))), + s"${this.name} yrittää purra henkilöä ${entity.name}, mutta epäonnistuu surkeasti." + ) diff --git a/src/scalevalapokalypsi/Model/Entities/Player.scala b/src/scalevalapokalypsi/Model/Entities/Player.scala index a66f521..7d166b4 100644 --- a/src/scalevalapokalypsi/Model/Entities/Player.scala +++ b/src/scalevalapokalypsi/Model/Entities/Player.scala @@ -27,7 +27,7 @@ class Player(name: String, initialLocation: Area) extends Entity(name, initialLo val res = (res1 ++ res2).toVector observations.clear() observedEvents.clear() - res + res.filter(s => !(s.isEmpty)) /** Returns whether this player has a pending sing effect. */ def isSinging: Boolean = this.pendingSingEffect.isDefined diff --git a/src/scalevalapokalypsi/Server/Server.scala b/src/scalevalapokalypsi/Server/Server.scala index 89db302..2ea8bd4 100644 --- a/src/scalevalapokalypsi/Server/Server.scala +++ b/src/scalevalapokalypsi/Server/Server.scala @@ -56,8 +56,7 @@ class Server( this.writeObservations() if this.canExecuteTurns then this.clients.foreach(_.giveTurn()) - //this.writeClientDataToClients() - //this.writeObservations() + this.adventure.foreach(_.takeNpcTurns()) this.clients.foreach(c => this.writeToClient(this.turnStartInfo(c), c) ) -- cgit v1.2.3