diff --git a/script/epapiscript.sbt b/script/epapiscript.sbt index 6fafbc5..d94f851 100644 --- a/script/epapiscript.sbt +++ b/script/epapiscript.sbt @@ -5,11 +5,11 @@ name := "EP API Script" organization := "com.lkroll.ep" -version := "0.5.3" +version := "0.6.0" scalaVersion := "2.12.4" -libraryDependencies += "com.lkroll.roll20" %%% "roll20-api-framework" % "0.7.+" +libraryDependencies += "com.lkroll.roll20" %%% "roll20-api-framework" % "0.8.+" libraryDependencies += "com.lkroll.ep" %%% "epcompendium-core" % "1.1.+" libraryDependencies += "com.lkroll.ep" %%% "ep-model" % "1.7.0" libraryDependencies += "com.lihaoyi" %%% "fastparse" % "1.+" diff --git a/script/src/main/scala/com/lkroll/ep/api/BattleManager.scala b/script/src/main/scala/com/lkroll/ep/api/BattleManager.scala new file mode 100644 index 0000000..0062000 --- /dev/null +++ b/script/src/main/scala/com/lkroll/ep/api/BattleManager.scala @@ -0,0 +1,443 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2017 Lars Kroll + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + */ +package com.lkroll.ep.api + +import com.lkroll.roll20.core._ +import com.lkroll.roll20.api._ +import com.lkroll.roll20.api.conf._ +import com.lkroll.roll20.api.templates._ +import com.lkroll.ep.model.{ EPCharModel => epmodel } +import scalajs.js +import scalajs.js.JSON +import fastparse.all._ +import concurrent.Future +import scala.util.{ Try, Success, Failure } +import scala.collection.mutable + +object BattleManagerScript extends APIScript { + override def apiCommands: Seq[APICommand[_]] = Seq(EPBattlemanCommand); + + onChange("campaign:turnorder", { (_, _) => EPBattlemanCommand.state match { + case EPBattlemanCommand.Active(_, _) => { + sendChat("Battleman (API)", + Chat.GM.message("WARN It is not recommended to manually change the turn order, while a battle is active in Battleman. You might end up in an inconsistent state!")); + } + case _ => (), // that's ok + }}); +} + +class EPBattlemanConf(args: Seq[String]) extends ScallopAPIConf(args) { + + val start = opt[Boolean]("start", descr = "Start a battle with all the characters currently in the turn order."); + val next = opt[Boolean]("next", descr = "Move to the next character, accounting for phases and turns."); + val end = opt[Boolean]("end", descr = "Ends a battle by clearing the turn order and internal state."); + val add = opt[Boolean]("add", descr = "Adds the currently selected tokens to the turn order and battleman. Use this while a battle is active, instead of manually adding tokens to the turn order."); + val drop = opt[Boolean]("drop", descr = "Removes the currently selected tokens from the turn order and battleman. Use this while a battle is active, instead of manually removing tokens from the turn order."); + + requireOne(start, next, end, add, drop); + verify(); +} + +object EPBattlemanCommand extends APICommand[EPBattlemanConf] { + import APIImplicits._; + import TurnOrder.{ Entry, CustomEntry, TokenEntry }; + override def command = "epbattleman"; + override def options = (args) => new EPBattlemanConf(args); + override def apply(config: EPBattlemanConf, ctx: ChatContext): Unit = { + if (config.start()) { + onStart(ctx) + } else if (config.next()) { + onNext(ctx) + } else if (config.end()) { + onEnd(ctx) + } else if (config.add()) { + onAdd(ctx) + } else if (config.drop()) { + onDrop(ctx) + } + } + + sealed trait State; + object Inactive extends State; + case class Active(round: Int, phase: Int) extends State; + + lazy val campaign = Campaign(); + lazy val turnOrder = campaign.turnOrder; + + private[api] var state: State = Inactive; + private val participants = mutable.Map.empty[String, (Token, Character)]; // token id -> token & represents + private val iniCache = mutable.Map.empty[String, Int]; // token id -> ini mod + + private def onStart(ctx: ChatContext) { + state match { + case Inactive => { + state = Active(1, 0); + var sorting = List.empty[(Int, Entry)]; + val order = turnOrder.get(); + order.foreach { + case e @ CustomEntry(_, Left(ini)) => { + sorting ::= (ini -> e); + } + case e @ TokenEntry(id, Left(ini)) => { + Graphic.get(id) match { + case Some(token: Token) => { + token.represents match { + case Some(char) => { + participants += (id -> (token, char)); + iniCache += (id -> getIniMod(char)); + sorting ::= (ini -> e); + } + case None => { + ctx.reply(s"Token ${token.name}(${token.id}) does not represent any character. Unlinked tokens are currently not supported."); + } + } + } + case Some(_: Card) => { + ctx.reply(s"Entry with id=${id} was a card, but we require a token!"); + } + case None => { + ctx.reply(s"Could not find token for id=${id}!"); + } + } + } + case e => { + ctx.reply(s"Got unexpected token $e! Ignoring."); + } + } + if (!sorting.isEmpty) { + val sorted = sorting.sortBy(_._1)(Ordering[Int].reverse); + val maxEntry = sorted.head._1 + 1; + val newOrder = marker(maxEntry) :: sorted.map(_._2); + turnOrder.set(newOrder); + val partString = participants.values.map(c => s"${c._2.name}").mkString(""); + val replyString = s"

Battle Participants

$partString

Battle Started!

"; + ctx.reply(replyString); + } else { + ctx.reply("There is no one to start a battle with :("); + } + } + case _ => { + ctx.reply("Can't start new battle, as there is already an ongoing battle."); + } + } + } + + private def getIniMod(c: Character): Int = { + val ini = c.attribute(epmodel.initiative).getOrDefault; + val iniRaw = c.attribute(epmodel.initiative).current; + val wounds = c.attribute(epmodel.woundsApplied).getOrDefault; + val woundsRaw = c.attribute(epmodel.woundsApplied).current; + debug(s"Got raw ini=${iniRaw} and wounds_applied=${woundsRaw} for ${c.name}"); + val traumas = c.attribute(epmodel.trauma).getOrDefault; + val misc = c.attribute(epmodel.miscInitiativeMod).getOrDefault; + val iniMod = ini - wounds - traumas + misc; + debug(s"Got iniMod=${iniMod}=${ini}-${wounds}-${traumas}+$misc for ${c.name}"); + iniMod + } + + private def onNext(ctx: ChatContext) { + state match { + case Active(round, phase) => { + val order = turnOrder.get(); + order match { + case head :: rest => { + head match { + case e @ CustomEntry(name, Left(ini)) => { + if (name.startsWith("|")) { // its our turn marker (probably^^) + if (rest.isEmpty) { // next turn + startNewTurn(); + } else { // next phase + state = Active(round, phase + 1); + val newOrder = rest ++ List(marker(ini)); + turnOrder.set(newOrder); + } + } else { // something else, just drop it + debug(s"Dropping custom entry $e"); + turnOrder.set(rest); + } + } + case e @ TokenEntry(id, Left(ini)) => { + participants.get(id) match { + case Some((_, c)) => { + val newOrder = if (shouldReschedule(c, phase)) { + validateAllInis(rest ++ List(e)) + } else { + validateAllInis(rest) + }; + turnOrder.set(newOrder); + } + case None => { + debug(s"Token is not a participant: $id. Dropping."); + turnOrder.set(rest); + } + } + } + case e => { + debug(s"Dropping unsupported entry $e"); + turnOrder.set(rest); + } + } + } + case Nil => { + startNewTurn(); + } + } + } + case _ => { + debug("Ignoring Battleman.next as no battle is active."); + } + } + } + + private def startNewTurn(): Unit = { + debug("Starting new turn..."); + EPGroupRollsCommand.rollInitiative(participants.values.toList) { + case Success(res) => { + state = state match { + case Active(round, phase) => Active(round + 1, 0) + case s => error("Battleman is in an invalid state!"); s + }; + var sorting = List.empty[(Int, Entry)]; + res.foreach { + case (token, character, ini) => { + val e = TokenEntry(token.id, Left(ini)); + sorting ::= (ini -> e); + } + } + if (!sorting.isEmpty) { + val sorted = sorting.sortBy(_._1)(Ordering[Int].reverse); + val maxEntry = sorted.head._1 + 1; + val newOrder = marker(maxEntry) :: sorted.map(_._2); + turnOrder.set(newOrder); + info("New turn is beginning."); + } else { + error("There is no one to continue the battle with :("); + } + } + case Failure(f) => error(f) + } + } + + private def validateAllInis(order: List[Entry]): List[Entry] = { + val changes = mutable.Map.empty[String, Int]; // token id -> ini diff + participants.foreach { + case (id, (token, char)) => { + val cur = iniCache(id); + val should = getIniMod(char); + if (cur != should) { + iniCache(id) = should; + val diff = should - cur; + debug(s"${char.name}'s ini differs by $diff"); + changes += (id -> diff); + } + } + } + if (changes.isEmpty) { + debug("No changes after ini validation."); + order + } else { + debug(s"Processing ${changes.size} changes after ini validation."); + var sortingPre = List.empty[(Int, Entry)]; + var sortingPost = List.empty[(Int, Entry)]; + + var preMarker = true; + order.foreach { + case e @ CustomEntry(name, _) => { + if (name.startsWith("|")) { // it's probably our marker + preMarker = false; + } else { + debug(s"Dropping custom entry $e during sorting."); + } + } + case e @ TokenEntry(id, Left(ini)) => { + changes.get(id) match { + case Some(diff) => { + val newIni = ini + diff; + val sEntry = (newIni -> TokenEntry(id, Left(newIni))); + if (preMarker) { + sortingPre ::= sEntry; + } else { + sortingPost ::= sEntry; + } + } + case None => if (preMarker) { + sortingPre ::= (ini -> e); + } else { + sortingPost ::= (ini -> e) + }; + } + } + case e => debug(s"Dropping entry $e during sorting."); + } + assembleWithMarker(sortingPre, sortingPost) + } + } + + private def shouldReschedule(c: Character, phase: Int): Boolean = { + val speed = c.attribute(epmodel.speed).getOrDefault; + speed > phase + } + + private def onEnd(ctx: ChatContext) { + state match { + case Active(round, phase) => { + state = Inactive; + turnOrder.clear(); + participants.clear(); + iniCache.clear(); + val time = round * 3; + ctx.reply(s"Battle ended in ${round} rounds (${time}s) and ${phase} phases."); + } + case _ => { + ctx.reply("There is no currently active battle to end."); + } + } + } + + private def marker(ini: Int): TurnOrder.CustomEntry = { + val s = state match { + case Active(round, phase) => s"|Round ${round}|Phase ${phase + 1}|" + case Inactive => "|Inactive|" + }; + TurnOrder.CustomEntry(s, Left(ini)) + } + + private def onAdd(ctx: ChatContext) { + val graphicTokens = ctx.selected; + if (graphicTokens.isEmpty) { + ctx.reply("No tokens selected. Nothing to do..."); + } else { + val tokens = graphicTokens.flatMap { + case t: Token => Some(t) + case c => debug(s"Ignoring non-Token $c"); None + }; + val targets = tokens.flatMap { token => + debug(s"Working on token: ${token.name} (${token.id})"); + token.represents match { + case Some(char) => { + debug(s"Token represents $char"); + EPScripts.checkVersion(char) match { + case Right(()) => Some((token, char)) + case Left(msg) => ctx.reply(msg + " Skipping token."); None + } + } + case None => ctx.reply(s"Token ${token.name}(${token.id}) does not represent any character!"); None + } + }; + debug(s"Adding ${targets.size} tokens to battleman."); + EPGroupRollsCommand.rollInitiative(targets) { + case Success(res) => { + res.foreach { + case (token, character, _) => { + participants += (token.id -> (token, character)); + iniCache += (token.id -> getIniMod(character)); + } + } + // put all new entries before the marker, since they should act this phase + var sortingPre: List[(Int, Entry)] = res.map { + case (token, character, ini) => { + (ini -> TokenEntry(token.id, Left(ini))) + } + }; + var sortingPost = List.empty[(Int, Entry)]; + + var preMarker = true; + turnOrder.get().foreach { + case e @ CustomEntry(name, _) => { + if (name.startsWith("|")) { // it's probably our marker + preMarker = false; + } else { + debug(s"Dropping custom entry $e during sorting."); + } + } + case e @ TokenEntry(id, Left(ini)) => { + if (preMarker) { + sortingPre ::= (ini -> e); + } else { + sortingPost ::= (ini -> e) + } + } + case e => debug(s"Dropping entry $e during sorting."); + } + val newOrder = assembleWithMarker(sortingPre, sortingPost); + turnOrder.set(newOrder); + val partString = res.map(c => s"${c._2.name}").mkString(""); + val replyString = s"

New Battle Participants

$partString"; + ctx.reply(replyString); + } + case Failure(f) => error(f); ctx.reply("Tokens could not be added to battleman. See log for error messages.") + } + } + } + + private def assembleWithMarker(sortingPre: List[(Int, Entry)], sortingPost: List[(Int, Entry)]): List[Entry] = { + (sortingPre.isEmpty, sortingPost.isEmpty) match { + case (true, true) => List.empty + case (true, false) => { + val sorted = sortingPost.sortBy(_._1)(Ordering[Int].reverse); + val maxEntry = sorted.head._1 + 1; + marker(maxEntry) :: sorted.map(_._2); + } + case (false, true) => { + val sorted = sortingPre.sortBy(_._1)(Ordering[Int].reverse); + val maxEntry = sorted.head._1 + 1; + sorted.map(_._2) ++ List(marker(maxEntry)) + } + case (false, false) => { + val sortedPost = sortingPost.sortBy(_._1)(Ordering[Int].reverse); + val sortedPre = sortingPre.sortBy(_._1)(Ordering[Int].reverse); + val maxEntry = Math.max(sortedPost.head._1, sortedPre.head._1) + 1; + val pre = sortedPre.map(_._2); + val post = sortedPost.map(_._2); + pre ++ (marker(maxEntry) :: post) + } + } + } + + private def onDrop(ctx: ChatContext) { + val graphicTokens = ctx.selected; + if (graphicTokens.isEmpty) { + ctx.reply("No tokens selected. Nothing to do..."); + } else { + val tokens = graphicTokens.flatMap { + case t: Token => Some(t) + case c => debug(s"Ignoring non-Token $c"); None + }; + val ids = tokens.map { token => + debug(s"Working on token: ${token.name} (${token.id})"); + participants.remove(token.id); + iniCache.remove(token.id); + token.id + }.toSet; + turnOrder.modify(_.filterNot { + case TokenEntry(id, _) => ids.contains(id) + case _ => false + } + ); + ctx.reply(s"Removed ${ids.size} tokens from battleman"); + } + } + +} diff --git a/script/src/main/scala/com/lkroll/ep/api/EPScripts.scala b/script/src/main/scala/com/lkroll/ep/api/EPScripts.scala index 06919fe..37d7e3d 100644 --- a/script/src/main/scala/com/lkroll/ep/api/EPScripts.scala +++ b/script/src/main/scala/com/lkroll/ep/api/EPScripts.scala @@ -36,7 +36,7 @@ import fastparse.all._ import util.{ Try, Success, Failure } object EPScripts extends APIScriptRoot { - override def children: Seq[APIScript] = Seq(RollsScript, TokensScript, GroupRollsScript, compendium.CompendiumScript); + override def children: Seq[APIScript] = Seq(RollsScript, TokensScript, GroupRollsScript, compendium.CompendiumScript, BattleManagerScript); val version = BuildInfo.version; val author = "Lars Kroll"; diff --git a/script/src/main/scala/com/lkroll/ep/api/GroupRolls.scala b/script/src/main/scala/com/lkroll/ep/api/GroupRolls.scala index 0fd94cd..8b51eaf 100644 --- a/script/src/main/scala/com/lkroll/ep/api/GroupRolls.scala +++ b/script/src/main/scala/com/lkroll/ep/api/GroupRolls.scala @@ -81,7 +81,7 @@ object EPGroupRollsCommand extends APICommand[EPGroupRollsConf] { } }; if (config.ini()) { - rollInitiative(targets, ctx); + rollInitiativeAndAddToTracker(targets, ctx); } else if (config.skill.isSupplied) { // TODO roll skill } else if (config.frayHalved()) { @@ -92,8 +92,9 @@ object EPGroupRollsCommand extends APICommand[EPGroupRollsConf] { } } - def rollInitiative(targets: List[(Token, Character)], ctx: ChatContext): Unit = { - val campaign = Campaign(); + type RollConsumer = Try[List[(Token, Character, Int)]] => Unit; + + def rollInitiative(targets: List[(Token, Character)])(f: RollConsumer): Unit = { val resFutures = targets.map { case (token, char) => { val strippedIni = epmodel.iniRoll.formula match { @@ -105,17 +106,23 @@ object EPGroupRollsCommand extends APICommand[EPGroupRollsConf] { } }; val resFuture = Future.sequence(resFutures); - resFuture.onComplete { + resFuture.onComplete(f); + } + + def rollInitiativeAndAddToTracker(targets: List[(Token, Character)], ctx: ChatContext): Unit = { + val f: RollConsumer = { case Success(res) => { + val campaign = Campaign(); campaign.turnOrder ++= res.map(t => (t._1 -> t._3)); campaign.turnOrder.dedup(); - campaign.turnOrder.sort(); + campaign.turnOrder.sortDesc(); val msg = "

Rolled Initiative

" + res.map(t => s"""${t._2.name}: [[${t._3}]] """) .mkString(""); ctx.reply(msg); } case Failure(e) => error(e); ctx.reply(s"Some rolls failed to complete."); - } + }; + rollInitiative(targets)(f) } } diff --git a/sheet/js/src/main/scala/com/lkroll/ep/sheet/EPWorkers.scala b/sheet/js/src/main/scala/com/lkroll/ep/sheet/EPWorkers.scala index b3d795a..7f00909 100644 --- a/sheet/js/src/main/scala/com/lkroll/ep/sheet/EPWorkers.scala +++ b/sheet/js/src/main/scala/com/lkroll/ep/sheet/EPWorkers.scala @@ -186,8 +186,6 @@ object EPWorkers extends SheetWorkerRoot { } } - // TODO ongoing updates notification - private[sheet] def searchSkillAndSetNameTotal(needle: String, section: RepeatingSection, nameField: TextField, totalField: FieldRefRepeating[Int]): Future[Unit] = { val rowId = Roll20.getActiveRepeatingField(); val simpleRowId = extractSimpleRowId(rowId);