diff --git a/build.sbt b/build.sbt index 56d62e46ec..8c11424e51 100644 --- a/build.sbt +++ b/build.sbt @@ -77,7 +77,7 @@ lazy val storm = module("storm", ) lazy val racer = module("racer", - Seq(common, memo, hub, puzzle, storm, db, user, pref, tree), + Seq(common, memo, hub, puzzle, storm, db, user, pref, tree, room), reactivemongo.bundle ) diff --git a/modules/challenge/src/main/ChallengeSocket.scala b/modules/challenge/src/main/ChallengeSocket.scala index e812148a1e..8e83967cbc 100644 --- a/modules/challenge/src/main/ChallengeSocket.scala +++ b/modules/challenge/src/main/ChallengeSocket.scala @@ -18,10 +18,10 @@ final private class ChallengeSocket( def reload(challengeId: Challenge.ID): Unit = rooms.tell(challengeId, NotifyVersion("reload", JsNull)) - lazy val rooms = makeRoomMap(send) - private lazy val send: String => Unit = remoteSocketApi.makeSender("chal-out").apply _ + lazy val rooms = makeRoomMap(send) + private lazy val challengeHandler: Handler = { case Protocol.In.OwnerPings(ids) => ids foreach api.ping } diff --git a/modules/racer/src/main/Env.scala b/modules/racer/src/main/Env.scala index ad3443cf5d..22473b76a9 100644 --- a/modules/racer/src/main/Env.scala +++ b/modules/racer/src/main/Env.scala @@ -20,9 +20,11 @@ final class Env( stormJson: StormJson, stormSign: StormSign, lightUserGetter: LightUser.GetterSync, + remoteSocketApi: lila.socket.RemoteSocket, db: lila.db.Db )(implicit - ec: scala.concurrent.ExecutionContext + ec: scala.concurrent.ExecutionContext, + mode: play.api.Mode ) { private lazy val colls = new RacerColls(puzzle = puzzleColls.puzzle) @@ -30,6 +32,8 @@ final class Env( lazy val api = wire[RacerApi] lazy val json = wire[RacerJson] + + private val socket = wire[RacerSocket] // requires eager eval } final private class RacerColls(val puzzle: AsyncColl) diff --git a/modules/racer/src/main/RacerApi.scala b/modules/racer/src/main/RacerApi.scala index 5416f5a9a4..85d4b8f18a 100644 --- a/modules/racer/src/main/RacerApi.scala +++ b/modules/racer/src/main/RacerApi.scala @@ -43,9 +43,27 @@ final class RacerApi(colls: RacerColls, selector: StormSelector, cacheApi: Cache get(id) map { race => race.join(player) match { case Some(joined) => - store.put(joined.id, joined) + saveAndPublish(joined) joined case None => race } } + + def registerPlayerMoves(id: RacerRace.Id, player: RacerPlayer.Id, moves: Int): Unit = + get(id) foreach { prev => + val race = prev.registerMoves(player, moves) + saveAndPublish(race) + } + + private def save(race: RacerRace): Unit = + store.put(race.id, race) + + private def saveAndPublish(race: RacerRace): Unit = { + save(race) + socket.foreach(_ publishState race) + } + + // work around circular dependency + private var socket: Option[RacerSocket] = None + private[racer] def registerSocket(s: RacerSocket) = { socket = s.some } } diff --git a/modules/racer/src/main/RacerJson.scala b/modules/racer/src/main/RacerJson.scala index cc09112161..4ac649e2cd 100644 --- a/modules/racer/src/main/RacerJson.scala +++ b/modules/racer/src/main/RacerJson.scala @@ -24,10 +24,18 @@ final class RacerJson(stormJson: StormJson, sign: StormSign, lightUserSync: Ligh "isOwner" -> (race.owner == playerId) ), "puzzles" -> race.puzzles, - "players" -> race.players.zipWithIndex.map { case (player, index) => - Json - .obj("index" -> (index + 1), "score" -> player.score) - .add("user" -> player.userId.flatMap(lightUserSync)) - } + "players" -> playersJson(race) ) + + def state(race: RacerRace) = Json.obj( + "players" -> playersJson(race) + ) + + private def playersJson(race: RacerRace) = JsArray { + race.players.zipWithIndex.map { case (player, index) => + Json + .obj("index" -> (index + 1), "moves" -> player.moves) + .add("user" -> player.userId.flatMap(lightUserSync)) + } + } } diff --git a/modules/racer/src/main/RacerPlayer.scala b/modules/racer/src/main/RacerPlayer.scala index 5dc587c0f7..ae02132ab0 100644 --- a/modules/racer/src/main/RacerPlayer.scala +++ b/modules/racer/src/main/RacerPlayer.scala @@ -3,7 +3,7 @@ package lila.racer import org.joda.time.DateTime import lila.user.User -case class RacerPlayer(id: RacerPlayer.Id, createdAt: DateTime, score: Int) { +case class RacerPlayer(id: RacerPlayer.Id, createdAt: DateTime, moves: Int) { import RacerPlayer.Id @@ -18,7 +18,10 @@ object RacerPlayer { object Id { case class User(userId: lila.user.User.ID) extends Id case class Anon(sessionId: String) extends Id + def apply(str: String) = + if (str startsWith "@") Anon(str drop 1) + else User(str) } - def make(id: Id) = RacerPlayer(id = id, score = 0, createdAt = DateTime.now) + def make(id: Id) = RacerPlayer(id = id, moves = 0, createdAt = DateTime.now) } diff --git a/modules/racer/src/main/RacerRace.scala b/modules/racer/src/main/RacerRace.scala index ef064d203c..8b5e471693 100644 --- a/modules/racer/src/main/RacerRace.scala +++ b/modules/racer/src/main/RacerRace.scala @@ -1,5 +1,6 @@ package lila.racer + import org.joda.time.DateTime import lila.user.User @@ -23,6 +24,14 @@ case class RacerRace( def join(id: RacerPlayer.Id): Option[RacerRace] = !has(id) && players.sizeIs <= RacerRace.maxPlayers option copy(players = players :+ RacerPlayer.make(id)) + + def registerMoves(playerId: RacerPlayer.Id, moves: Int): RacerRace = + copy( + players = players map { + case p if p.id == playerId => p.copy(moves = moves) + case p => p + } + ) } object RacerRace { diff --git a/modules/racer/src/main/RacerSocket.scala b/modules/racer/src/main/RacerSocket.scala new file mode 100644 index 0000000000..975fea4f3c --- /dev/null +++ b/modules/racer/src/main/RacerSocket.scala @@ -0,0 +1,59 @@ +package lila.racer + +import lila.room.RoomSocket.{ Protocol => RP, _ } +import lila.socket.RemoteSocket.{ Protocol => P, _ } +import play.api.libs.json.{ JsObject, Json } + +final private class RacerSocket( + api: RacerApi, + json: RacerJson, + remoteSocketApi: lila.socket.RemoteSocket +)(implicit + ec: scala.concurrent.ExecutionContext, + mode: play.api.Mode +) { + + import RacerSocket._ + + def publishState(race: RacerRace): Unit = send( + Protocol.Out.publishState(race.id, json state race).pp + ) + + private lazy val send: String => Unit = remoteSocketApi.makeSender("racer-out").apply _ + + lazy val rooms = makeRoomMap(send) + + private lazy val racerHandler: Handler = { case Protocol.In.PlayerMoves(raceId, playerId, moves) => + api.registerPlayerMoves(raceId, playerId, moves) + } + + remoteSocketApi.subscribe("racer-in", Protocol.In.reader)( + racerHandler orElse minRoomHandler(rooms, logger) orElse remoteSocketApi.baseHandler + ) + + api registerSocket this +} + +object RacerSocket { + + object Protocol { + + object In { + + case class PlayerMoves(race: RacerRace.Id, player: RacerPlayer.Id, moves: Int) extends P.In + + val reader: P.In.Reader = raw => + raw.path match { + case "racer/moves" => + raw.get(3) { case Array(raceId, playerId, moveStr) => + moveStr.toIntOption map { PlayerMoves(RacerRace.Id(raceId), RacerPlayer.Id(playerId), _) } + } + } + } + + object Out { + + def publishState(id: RacerRace.Id, data: JsObject) = s"racer/state $id ${Json stringify data}" + } + } +} diff --git a/ui/puz/src/util.ts b/ui/puz/src/util.ts index 5a5978a47a..fb76706416 100644 --- a/ui/puz/src/util.ts +++ b/ui/puz/src/util.ts @@ -24,3 +24,9 @@ export const loadSound = (file: string, volume?: number, delay?: number) => { setTimeout(() => lichess.sound.loadOggOrMp3(file, `${lichess.sound.baseUrl}/${file}`), delay || 1000); return () => lichess.sound.play(file, volume); }; + +export const sound = { + move: (take: boolean) => lichess.sound.play(take ? 'capture' : 'move'), + bonus: loadSound('other/ping', 0.8, 1000), + end: loadSound('other/gewonnen', 0.5, 5000), +}; diff --git a/ui/racer/src/ctrl.ts b/ui/racer/src/ctrl.ts index 65a9a8a1bc..1b0886aee7 100644 --- a/ui/racer/src/ctrl.ts +++ b/ui/racer/src/ctrl.ts @@ -2,12 +2,12 @@ import config from 'puz/config'; import makePromotion from 'puz/promotion'; import sign from 'puz/sign'; import { Api as CgApi } from 'chessground/api'; -import { getNow, loadSound } from 'puz/util'; +import { getNow, sound } from 'puz/util'; import { makeCgOpts, onBadMove, onGoodMove } from 'puz/run'; import { parseUci } from 'chessops/util'; import { prop, Prop } from 'common'; import { Role } from 'chessground/types'; -import { RacerOpts, RacerData, RacerVm, RacerPrefs, Race } from './interfaces'; +import { RacerOpts, RacerData, RacerVm, RacerPrefs, Race, ServerState } from './interfaces'; import { Promotion, Run } from 'puz/interfaces'; import { Combo } from 'puz/combo'; import CurrentPuzzle from 'puz/current'; @@ -46,6 +46,11 @@ export default class StormCtrl { }; this.promotion = makePromotion(this.withGround, () => makeCgOpts(this.run), this.redraw); if (this.data.key) setTimeout(() => sign(this.data.key!).then(this.vm.signed), 1000 * 40); + lichess.socket = new lichess.StrongSocket(`/racer/${this.race.id}`, false); + lichess.pubsub.on('socket.in.racerState', (state: ServerState) => { + this.data.players = state.players; + this.redraw(); + }); } players = () => this.data.players; @@ -55,7 +60,7 @@ export default class StormCtrl { this.run.endAt = getNow(); this.ground(false); this.redraw(); - this.sound.end(); + sound.end(); this.redrawSlow(); }; @@ -72,23 +77,24 @@ export default class StormCtrl { this.run.clock.start(); this.run.moves++; this.promotion.cancel(); - const cur = this.run.current; + const puzzle = this.run.current; const uci = `${orig}${dest}${promotion ? (promotion == 'knight' ? 'n' : promotion[0]) : ''}`; - const pos = cur.position(); + const pos = puzzle.position(); const move = parseUci(uci)!; let captureSound = pos.board.occupied.has(move.to); pos.play(move); - if (pos.isCheckmate() || uci == cur.expectedMove()) { - cur.moveIndex++; - if (onGoodMove(this.run)) this.sound.bonus(); - if (cur.isOver()) { + if (pos.isCheckmate() || uci == puzzle.expectedMove()) { + puzzle.moveIndex++; + onGoodMove(this.run); + lichess.pubsub.emit('socket.send', 'racerMoves', this.run.moves); + if (puzzle.isOver()) { this.pushToHistory(true); if (!this.incPuzzle()) this.end(); } else { - cur.moveIndex++; - captureSound = captureSound || pos.board.occupied.has(parseUci(cur.line[cur.moveIndex]!)!.to); + puzzle.moveIndex++; + captureSound = captureSound || pos.board.occupied.has(parseUci(puzzle.line[puzzle.moveIndex]!)!.to); } - this.sound.move(captureSound); + sound.move(captureSound); } else { lichess.sound.play('error'); this.pushToHistory(false); @@ -128,10 +134,4 @@ export default class StormCtrl { const g = this.ground(); return g && f(g); }; - - private sound = { - move: (take: boolean) => lichess.sound.play(take ? 'capture' : 'move'), - bonus: loadSound('other/ping', 0.8, 1000), - end: loadSound('other/gewonnen', 0.6, 5000), - }; } diff --git a/ui/racer/src/interfaces.ts b/ui/racer/src/interfaces.ts index e9a3da6340..20cdbec3dc 100644 --- a/ui/racer/src/interfaces.ts +++ b/ui/racer/src/interfaces.ts @@ -24,10 +24,14 @@ export interface Race { startRel?: number; } +export interface ServerState { + players: Player[]; +} + export interface Player { index: number; user?: LightUser; - score: number; + moves: number; } export interface RacerVm { diff --git a/ui/racer/src/view/race.ts b/ui/racer/src/view/race.ts index abd8e70b8e..d9e7017247 100644 --- a/ui/racer/src/view/race.ts +++ b/ui/racer/src/view/race.ts @@ -8,7 +8,7 @@ export const renderRace = (ctrl: RacerCtrl) => h('div.racer__race', ctrl.players const renderPlayer = (player: Player) => h('div.racer__race__player', [ h('div.racer__race__player__name', player.user ? userName(player.user) : ['Anon', ' ', player.index]), - h(`div.racer__race__player__car.car-${player.index}`, [player.score]), + h(`div.racer__race__player__car.car-${player.index}`, [player.moves]), ]); export const userName = (user: LightUser): Array => diff --git a/ui/storm/src/ctrl.ts b/ui/storm/src/ctrl.ts index abb04853e9..e7129b90e7 100644 --- a/ui/storm/src/ctrl.ts +++ b/ui/storm/src/ctrl.ts @@ -3,7 +3,7 @@ import config from 'puz/config'; import makePromotion from 'puz/promotion'; import sign from 'puz/sign'; import { Api as CgApi } from 'chessground/api'; -import { getNow, loadSound } from 'puz/util'; +import { getNow, sound } from 'puz/util'; import { countWins, makeCgOpts, onBadMove, onGoodMove } from 'puz/run'; import { parseUci } from 'chessops/util'; import { prop, Prop } from 'common'; @@ -62,7 +62,7 @@ export default class StormCtrl { this.run.endAt = getNow(); this.ground(false); this.redraw(); - this.sound.end(); + sound.end(); xhr.record(this.runStats(), this.data.notAnExploit).then(res => { this.vm.response = res; this.redraw(); @@ -83,23 +83,23 @@ export default class StormCtrl { this.run.clock.start(); this.run.moves++; this.promotion.cancel(); - const cur = this.run.current; + const puzzle = this.run.current; const uci = `${orig}${dest}${promotion ? (promotion == 'knight' ? 'n' : promotion[0]) : ''}`; - const pos = cur.position(); + const pos = puzzle.position(); const move = parseUci(uci)!; let captureSound = pos.board.occupied.has(move.to); pos.play(move); - if (pos.isCheckmate() || uci == cur.expectedMove()) { - cur.moveIndex++; - if (onGoodMove(this.run)) this.sound.bonus(); - if (cur.isOver()) { + if (pos.isCheckmate() || uci == puzzle.expectedMove()) { + puzzle.moveIndex++; + onGoodMove(this.run); + if (puzzle.isOver()) { this.pushToHistory(true); if (!this.incPuzzle()) this.end(); } else { - cur.moveIndex++; - captureSound = captureSound || pos.board.occupied.has(parseUci(cur.line[cur.moveIndex]!)!.to); + puzzle.moveIndex++; + captureSound = captureSound || pos.board.occupied.has(parseUci(puzzle.line[puzzle.moveIndex]!)!.to); } - this.sound.move(captureSound); + sound.move(captureSound); } else { lichess.sound.play('error'); this.pushToHistory(false); @@ -154,12 +154,6 @@ export default class StormCtrl { this.redraw(); }; - private sound = { - move: (take: boolean) => lichess.sound.play(take ? 'capture' : 'move'), - bonus: loadSound('other/ping', 0.8, 1000), - end: loadSound('other/gewonnen', 0.6, 5000), - }; - private checkDupTab = () => { const dupTabMsg = lichess.storage.make('storm.tab'); dupTabMsg.fire(this.data.puzzles[0].id);