import Router from "@koa/router" ; import assert from "assert" ; import createDebug from "debug" ; import leven from "leven" ; import { Context } from "koa" ; import { v4 as uuid } from "uuid" ; import createGame , { Game , PrettyMove } from "../util/chess" ; import { DrawCtrl } from "./draw" ; import { JsonLdService } from "../shared/jsonld" ; import { ObjectExt } from "./inbox" ; import { OutboxCtrl } from "./outbox" ; import { createElement as h , createMention , render } from "../util/html" ; import { getActor } from "../util/rdfModel" ; import { renderPgn } from "../util/pgn" ; import { renderTemplate } from "../util/fs" ; import { sample , sortBy } from "../util/misc" ; import { AS , AS_CONTEXT , CHESS_CONTEXT , AS_MIME , PGN_MIME , KOA_JSON_ACCEPTS , SHORT_CACHE_SEC , UNICODE_BADGES , UNICODE_PIECES , } from "../util/consts" ; import { Pg , GameRow , transact , getGameById , getGameByObjectForUpdate , insertGame , insertGameObject , updateGame , getOutboxMovesByGame , removeFromChallengeBoard , } from "../util/model" ; export interface GameCtrl { handleChallenge ( object ) : Promise < void > ; handleReply ( object ) : Promise < boolean > ; } type GameMeta = Pick < GameRow , "id" | "badge" | "whiteId" | "whiteName" | "blackId" | "blackName" > ; const UUID_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i ; const CHALLENGER_TYPES = new Set ( [ AS ( "Person" ) ] ) ; const CHALLENGED_TYPES = new Set ( [ AS ( "Person" ) , AS ( "Service" ) , AS ( "Application" ) , ] ) ; const debug = createDebug ( "chess:game" ) ; export default async ( { actorUrl , domain , draw , jsonld , origin , outbox , pg , router , } ) : Promise < GameCtrl > => { const handleChallenge = async ( object ) => { const challengerActor = object . actor ; if ( ! CHALLENGER_TYPES . has ( challengerActor . type || "" ) ) { debug ( "Challenge from invalid actor type" ) ; return ; } const mentions = new Set ( object . mentions ) ; mentions . delete ( actorUrl ) ; mentions . delete ( challengerActor . id ) ; if ( mentions . size !== 1 ) { debug ( "Challenge contained invalid mentions" ) ; return ; } const otherId = mentions . values ( ) . next ( ) . value ; const store = jsonld . createStore ( ) ; try { await store . load ( otherId ) ; } catch ( err ) { console . log ( `Failed to load challenged actor document: ${ otherId }` ) ; console . log ( `Error: ${ err . message }` ) ; return ; } const otherActor = getActor ( store , otherId ) ; if ( ! CHALLENGED_TYPES . has ( otherActor . type || "" ) ) { debug ( "Challenge to invalid actor type" ) ; return ; } const [ whiteActor , blackActor ] = Math . random ( ) > 0.5 ? [ challengerActor , otherActor ] : [ otherActor , challengerActor ] ; const game = createGame ( ) ; const row = { id : uuid ( ) , badge : sample ( UNICODE_BADGES ) , whiteId : whiteActor . id , whiteName : whiteActor . preferredUsername || "???" , blackId : blackActor . id , blackName : blackActor . preferredUsername || "???" , } ; await transact ( pg , async ( pg ) => { const now = new Date ( ) ; const { rowCount } = await insertGame ( pg , row . id , row . whiteId , row . whiteName , row . blackId , row . blackName , game . fen ( ) , row . badge , now ) ; assert . strictEqual ( rowCount , 1 ) ; await insertGameObjectChecked ( pg , row . id , object . id ) ; await removeFromChallengeBoard ( pg , otherActor . id ) ; await finishWithReply ( pg , object , game , row , { createdAt : now , } ) ; } ) ; } ; const handleReply = async ( object ) => { let result = false ; await transact ( pg , async ( pg ) => { const { rows : gameRows } = await getGameByObjectForUpdate ( pg , object . inReplyTo ! ) ; if ( gameRows . length !== 1 ) { debug ( "Reply unrelated to a game" ) ; return ; } const row = gameRows [ 0 ] ; const game = createGame ( row . fen ) ; await insertGameObjectChecked ( pg , row . id , object . id ) ; result = true ; if ( game . isGameOver ( ) ) { debug ( "Reply to finished game" ) ; return ; } const turnId = game . turn ( ) === "w" ? row . whiteId : row . blackId ; if ( object . actor . id !== turnId ) { debug ( "Reply from wrong actor" ) ; return ; } const input = object . contentText . replace ( /@[^\s]*/g , "" ) . trim ( ) . replace ( /[?!.,;\s].*$/ , "" ) ; if ( ! input ) { debug ( "Reply contained no move" ) ; return ; } const move = game . move ( input ) ; if ( ! move ) { debug ( "Reply contained invalid move" ) ; await suggestMove ( pg , object , game , row , input ) ; return ; } const now = new Date ( ) ; const { rowCount } = await updateGame ( pg , row . id , game . fen ( ) , game . isGameOver ( ) , now ) ; assert . strictEqual ( rowCount , 1 ) ; await finishWithReply ( pg , object , game , row , { move } ) ; } ) ; return result ; } ; const finishWithReply = async ( pg , object , game , row , opts = { } ) : Promise < void > => { const move = opts . move ; let line1 , line2 , line3 ; if ( move ) { const piece = UNICODE_PIECES [ move . color + move . piece ] ; const opponent = move . color === "w" ? "b" : "w" ; let descr ; if ( move . flags . includes ( "k" ) ) { descr = `${ piece } castled king-side` ; } else if ( move . flags . includes ( "q" ) ) { descr = `${ piece } castled queen-side` ; } else { descr = `${ piece } ${ move . from } → ${ move . to }` ; if ( move . flags . includes ( "p" ) ) { const promotion = UNICODE_PIECES [ move . color + move . promotion ] ; descr += `, promoted to ${ promotion }` ; } else if ( move . flags . includes ( "c" ) ) { const captured = UNICODE_PIECES [ opponent + move . captured ] ; descr += `, captured ${ captured }` ; } else if ( move . flags . includes ( "e" ) ) { const captured = UNICODE_PIECES [ opponent + move . captured ] ; descr += `, en passant captured ${ captured }` ; } } line1 = [ `${ row . badge } [${ move . number }. ${ move . san }] ${ descr }` ] ; } else { line1 = [ `${ row . badge } ♙ ` , createMention ( row . whiteId , row . whiteName ) , " vs. ♟ " , createMention ( row . blackId , row . blackName ) , ] ; const gameUrl = `${ origin }/games/${ row . id }` ; line3 = [ "View the full game at any time at: " , h ( "a" , { href : gameUrl } , [ gameUrl ] ) , ] ; } if ( game . isInCheckmate ( ) ) { line2 = [ "Checkmate." ] ; } else if ( game . isInDraw ( ) ) { line2 = [ "Draw." ] ; } else { line2 = [ game . turn ( ) === "w" ? createMention ( row . whiteId , row . whiteName ) : createMention ( row . blackId , row . blackName ) , "'s turn" , ] ; if ( game . isInCheck ( ) ) { line2 . push ( " (Check)" ) ; } if ( ! move ) { line2 . push ( ", reply with your move." ) ; } } const replyContent = [ h ( "p" , { } , line1 ) , h ( "p" , { } , line2 ) ] ; if ( line3 ) { replyContent . push ( h ( "p" , { } , line3 ) ) ; } const fen = game . fen ( ) ; const images = draw . imageUrls ( fen , move ) ; const createdAt = opts . createdAt || new Date ( ) ; const replyId = await outbox . createObject ( pg , { "@context" : [ AS_CONTEXT , CHESS_CONTEXT ] , type : "Note" , published : createdAt . toISOString ( ) , attributedTo : actorUrl , inReplyTo : object . id , to : [ row . whiteId , row . blackId ] , content : render ( replyContent ) , attachment : [ { type : "Image" , mediaType : "image/png" , url : images . moveImage , } , ] , tag : [ { type : "Mention" , href : row . whiteId } , { type : "Mention" , href : row . blackId } , ] , game : `${ origin }/games/${ row . id }` , san : move ? move . san : undefined , fen , ... images , } ) ; await insertGameObjectChecked ( pg , row . id , replyId ) ; } ; const suggestMove = async ( pg , object , game , row , input ) : Promise < void > => { const moves = sortBy ( [ ... game . moves ( ) ] , ( move ) => leven ( input , move ) ) . slice ( 0 , 5 ) . map ( ( x ) => `'${ x }'` ) ; assert ( moves . length >= 1 ) ; const lastMove = moves . pop ( ) ; const movesText = moves . length === 0 ? lastMove : `${ moves . join ( ", " ) } or ${ lastMove }` ; const actor = object . actor ; const replyContent = render ( h ( "p" , { } , [ createMention ( actor . id , actor . preferredUsername || "???" ) , " " , sample ( [ "That appears to be invalid!" , "I can't make that work, I'm afraid." , "Does not compute!" , "I don't know what to do with that." , "A small misunderstanding." , ] ) , " " , sample ( [ `Perhaps you want ${ movesText }?` , `Did you mean ${ movesText }?` , `Maybe ${ movesText }?` , `Looking for ${ movesText }?` , ] ) , ] ) ) ; const replyId = await outbox . createObject ( pg , { "@context" : [ AS_CONTEXT , CHESS_CONTEXT ] , type : "Note" , published : new Date ( ) . toISOString ( ) , attributedTo : actorUrl , inReplyTo : object . id , to : [ actor . id ] , content : replyContent , tag : [ { type : "Mention" , href : actor . id } ] , game : `${ origin }/games/${ row . id }` , } ) ; await insertGameObjectChecked ( pg , row . id , replyId ) ; } ; const insertGameObjectChecked = async ( pg , gameId , objectId ) : Promise < void > => { const { rowCount } = await insertGameObject ( pg , gameId , objectId ) ; assert . strictEqual ( rowCount , 1 ) ; } ; router . get ( "/games/:id" , async ( ctx ) => { ctx . assert ( UUID_REGEXP . test ( ctx . params . id ) , 404 , "Game not found" ) ; const { rows : gameRows } = await getGameById ( pg , ctx . params . id ) ; ctx . assert ( gameRows . length === 1 , 404 , "Game not found" ) ; const gameRow = gameRows [ 0 ] ; const { rows : moveRows } = await getOutboxMovesByGame ( pg , gameRow . id ) ; ctx . assert ( moveRows . length >= 1 ) ; const notes = moveRows . map ( ( row ) => ( { ... row . object , "@context" : undefined , } ) ) ; const game = { "@context" : [ AS_CONTEXT , CHESS_CONTEXT ] , id : `${ origin }/games/${ gameRow . id }` , fen : gameRow . fen , badge : gameRow . badge , whiteActor : gameRow . whiteId , blackActor : gameRow . blackId , whiteUsername : gameRow . whiteName , blackUsername : gameRow . blackName , setupNote : notes [ 0 ] , moves : notes . slice ( 1 ) , } ; if ( ! gameRow . gameOver ) { ctx . set ( "Cache-Control" , `public, max-age=${ SHORT_CACHE_SEC }` ) ; } const wantPgn = ctx . query . pgn !== undefined ; wantPgn || ctx . response . vary ( "accept" ) ; if ( ! wantPgn && ctx . accepts ( "html" ) ) { ctx . body = await renderTemplate ( "game" , { domain , game } ) ; ctx . type = "html" ; } else if ( wantPgn || ctx . accepts ( PGN_MIME ) ) { ctx . body = renderPgn ( { game , origin } ) ; ctx . type = PGN_MIME ; ctx . attachment ( `${ gameRow . id }.pgn` ) ; } else if ( ctx . accepts ( KOA_JSON_ACCEPTS ) ) { ctx . body = game ; ctx . type = AS_MIME ; } else { ctx . status = 406 ; } } ) ; return { handleChallenge , handleReply } ; } ;