diff --git a/startup/accounts.js b/api/accounts.ts similarity index 100% rename from startup/accounts.js rename to api/accounts.ts diff --git a/api/games/_logic/Bet.ts b/api/games/_logic/Bet.ts new file mode 100644 index 0000000000000000000000000000000000000000..3eda988863f4831e3c0ef0726d2e2316986a22b0 --- /dev/null +++ b/api/games/_logic/Bet.ts @@ -0,0 +1,5 @@ +export const enum Bet { + NONE = 0, + TICHU = 100, + GRAND = 200 +} diff --git a/api/games/_logic/Card.ts b/api/games/_logic/Card.ts new file mode 100644 index 0000000000000000000000000000000000000000..24c92fb5425b19a5414b061686b8d59425cb0daf --- /dev/null +++ b/api/games/_logic/Card.ts @@ -0,0 +1,95 @@ +export const enum Color { + DOG, + MAHJONG, + PHOENIX, + BLUE, + GREEN, + RED, + BLACK, + DRAGON, + MAX_COLOR +} + +export class CardData { + color: Color + value: number +} + +export class Card { + color: Color + value: number + + constructor (color: Color, value: number) { + this.color = color + this.value = value + } + + isPhoenix (): boolean { + return this.color === Color.PHOENIX + } + + /** + * The number of points this Card is worth. + */ + points (): number { + if (this.color === Color.PHOENIX) return -25 + else if (this.color === Color.DRAGON) return 25 + else if (this.value === 5) return 5 + else if (this.value === 10 || this.value === 13) return 10 + else return 0 + } + + static get DOG (): Card { + return new Card(Color.DOG, 0) + } + + static get MAHJONG (): Card { + return new Card(Color.MAHJONG, 1) + } + + static get PHOENIX (): Card { + return new Card(Color.PHOENIX, 0) + } + + static get DRAGON (): Card { + return new Card(Color.DRAGON, 15) + } + + static fromObject (obj: CardData): Card { + return new Card(obj.color, obj.value) + } + + toObject (): CardData { + return Object.assign({}, this) + } +} + +/** + * Compare two cards. Used for display. + * + * @return {number} Negative when c1 is before c2, + * null when they're the same, + * positive when c2 is before c1. + */ +export function compareCards (c1: Card, c2: Card): number { + if (c1.color < 2 && c2.color < 2) return c2.color - c1.color + else if (c1.color < 2) return 1 + else if (c2.color < 2) return -1 + else if (c1.color === Color.DRAGON && c2.color === Color.DRAGON) return 0 + else if (c1.color === Color.DRAGON) return -1 + else return c2.value - c1.value + (c2.color - c1.color) / Color.MAX_COLOR +} + +/** + * Whether two cards are the same. + */ +export function cardsEqual (c1: Card, c2: Card): boolean { + return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || (c1.color === c2.color && c1.value === c2.value) +} + +/** + * Whether two cards are of the same value. + */ +export function cardsValueEqual (c1: Card, c2: Card): boolean { + return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || c1.value === c2.value +} diff --git a/api/games/_logic/CardSet.ts b/api/games/_logic/CardSet.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c90e10fda8b2749bdcb94f1856d954f9ec2be4f --- /dev/null +++ b/api/games/_logic/CardSet.ts @@ -0,0 +1,86 @@ +import { SortedArraySet } from 'collections/sorted-array-set' +import { Card, CardData, cardsEqual, compareCards } from './Card' +import { Trick } from './Trick' + +export class CardSet extends SortedArraySet<Card> { + /** + * The trick corresponding to this CardSet, or null if it has not be computed yet. + * It is used to not compute the trick every time `CardSet.trick` is called. + */ + private _cached_trick: Trick | null + + constructor (cards: Card[] = []) { + super(cards, cardsEqual, compareCards, () => Card.DOG) + this._cached_trick = null + } + + // SET METHODS + + clear (): void { + super.clear() + this._cached_trick = null + } + + add (card: Card): boolean { + this._cached_trick = null + return super.add(card) + } + + push (...cards: Card[]): void { + this._cached_trick = null + return super.push(...cards) + } + + remove (card: Card) { + this._cached_trick = null + return super.remove(card) + } + + // CARDS METHODS + + /** + * Whether this CardSet can be played over the `other` CardSet. + */ + canBePlayedOver (other: CardSet): boolean { + if (other.length === 0 || (this.trick().isBomb() && !other.trick().isBomb())) { + // Rien n'a été joué. + // ou + // On a joué une bombe sur une combinaison normale. + return true + } else if ((!other.trick().isBomb() && this.length !== other.length) || (!this.trick().isBomb() && other.trick().isBomb())) { + // On joue sur une combinaison normale, et notre combinaison n'a pas le même nombre de cartes que l'autre. + // ou + // On joue une combinaison normale sur une bombe. + return false + } else if (this.trick().length === 1 && this.has(Card.PHOENIX)) { + return !other.has(Card.DRAGON) + } else { + return this.trick().value - other.trick().value > 0 + } + } + + /** + * The number of points this CardSet is worth. + */ + points (): number { + return this.reduce((p, c) => p + c.points(), 0) + } + + /** + * The trick corresponding to this CardSet. + */ + trick (): Trick { + if (this._cached_trick == null) { + this._cached_trick = Trick.fromSortedArray(this.toArray()) + } + return this._cached_trick + } + + static fromObject (obj: CardData[]): CardSet { + return new CardSet(obj.map(Card.fromObject)) + } + + toObject (): CardData[] { + return this.toArray().map(c => c.toObject()) + } +} diff --git a/api/games/_logic/GamePov.ts b/api/games/_logic/GamePov.ts new file mode 100644 index 0000000000000000000000000000000000000000..0dd7c1134832640b9cf0047787c6de130ba19cdf --- /dev/null +++ b/api/games/_logic/GamePov.ts @@ -0,0 +1,76 @@ +import { GameState, GameSystem, Place } from './GameSystem' +import { Card } from './Card' + +/** 0 (west), 1 (north), 2 (east) or null (south) */ +export declare type RelativePlace = null | 0 | 1 | 2 + +/** + * A game from the point of view of a player placed at a given place. + */ +export class GamePov { + /** + * Underlying game. + */ + private _game: GameSystem + + /** + * The place of the player. + */ + private readonly _place: Place + + constructor (game: GameSystem, place: Place) { + this._game = game + this._place = place + } + + get hand (): Card[] { + return this._game._players[this._place].hand.toArray() + } + + get givingCards (): boolean { + return this._game._state === GameState.GIVING_CARDS + } + + get givingDragon (): boolean { + return this._game._state === GameState.GIVING_DRAGON && this._game._playing === this._place + } + + isPlaying (relativePlace: RelativePlace): boolean { + let place = this._absolutePlace(relativePlace) + return this._game._playing === ((this._place + place) % 4) + } + + hasLead (relativePlace: RelativePlace): boolean { + let place = this._absolutePlace(relativePlace) + return this._game._lead === ((this._place + place) % 4) + } + + get discard (): Card[] | null { + if (this._game._discard.length > 0) { + return this._game._discard[this._game._discard.length - 1].toArray() + } else { + return null + } + } + + bet (relativePlace: RelativePlace): number { + let place = this._absolutePlace(relativePlace) + return this._game._players[place].bet + } + + numCards (relativePlace: RelativePlace): number { + let place = this._absolutePlace(relativePlace) + return this._game._players[place].hand.length + } + + /** + * From a place relative to this._place returns the absolute place in the game. + */ + private _absolutePlace (rel: RelativePlace): Place { + if (rel == null) { + return this._place + } else { + return <Place>((this._place - rel + 3) % 4) + } + } +} diff --git a/api/games/_logic/GameSystem.ts b/api/games/_logic/GameSystem.ts new file mode 100644 index 0000000000000000000000000000000000000000..5931d8d9e6e5ee57e07c5ec4d4868299afdbca0d --- /dev/null +++ b/api/games/_logic/GameSystem.ts @@ -0,0 +1,407 @@ +import { Card, CardData } from './Card' +import { Player, PlayerData } from './Player' +import { CardSet } from './CardSet' +import { fullDeck, shuffle, splitChunk } from './deck' +import { Bet } from './Bet' + +export declare type Place = 0 | 1 | 2 | 3 + +export const NUM_PLAYERS = 4 +//export const NUM_CARDS = 56 + +export const enum GameState { + GIVING_CARDS = 1, + PLAYING, + GIVING_DRAGON +} + +function initialGameState (): GameState { + return GameState.GIVING_CARDS +} + +export class GameSystemData { + _players: PlayerData[] + _points02: number[] + _points13: number[] + _discard: CardData[][] + _state: GameState + _first: Place | -1 + _playing: Place | -1 + _lead: Place | -1 + _maxPoints: number +} + +export class GameSystem { + /** + * The array of players. + * The (n+1)th player is at the right of the nth player, modulo the number of players. + */ + _players: Player[] + + /** + * The number of points the team formed by the 0th and the 2th player has earned during each round. + */ + _points02: number[] + + /** + * The number of points the team formed by the 1th and the 3th player has earned during each round. + */ + _points13: number[] + + /** + * The discard, as a stack of the played tricks. + */ + _discard: CardSet[] + + /** + * The state of the game. + */ + _state: GameState + + /** + * The first player that has finished the round, or -1 if there is none. + */ + _first: Place | -1 + + /** + * The player that has to play, or -1 if there is none (GameState != PLAYING) + */ + _playing: Place | -1 + + /** + * The player that will get the discard if the others skip turns, or -1 if there is none. + */ + _lead: Place | -1 + + /** + * The point cap to reach for the game to end. + */ + _maxPoints: number + + constructor () { + this._players = [] + this._points02 = [] + this._points13 = [] + this._discard = [] + this._state = initialGameState() + this._first = -1 + this._playing = -1 + this._lead = -1 + this._maxPoints = 100 + + for (let i = 0; i < NUM_PLAYERS; i++) { + this._players.push(new Player()) + } + } + + /////////// + // UTILS // + /////////// + + /** + * Whether the game is over. + * + * @return {number} -1 if the game is not over + * 0 if the team 02 wins + * 1 if the team 13 wins + */ + winner (): -1 | 0 | 1 { + let points02 = this._points02.reduce((s, p) => s + p) + let points13 = this._points13.reduce((s, p) => s + p) + + if ((points02 >= this._maxPoints || points13 >= this._maxPoints) && points02 !== points13) { + return points02 > points13 ? 0 : 1 + } else { + return -1 + } + } + + /** + * Counts the points of each player and add them to the points of the round. + * A new round must start after a call to this method. + */ + private _countPoints (): void { + let points02 = 0 + let points13 = 0 + let unfinished = [] + + for (let i = 0; i < NUM_PLAYERS; i++) { + if (this._players[i].hand.length > 0) { + unfinished.push(i) + } + } + + if (unfinished.length === 1) { + let last = unfinished[0] + + if (last % 2 === 0) { + // Last player is in team 02 + points13 += this._players[last].hand.points() + } else { + points02 += this._players[last].hand.points() + } + + if (this._first % 2 === 0) { + // First player is in team 02 + points02 += this._players[last].tricks.points() + } else { + points13 += this._players[last].tricks.points() + } + + this._players[last].tricks.clear() + points02 += this._players[0].tricks.points() + this._players[2].tricks.points() + points13 += this._players[1].tricks.points() + this._players[3].tricks.points() + } else if (this._players[0].hand.length > 0) { + // One-Two. Team 13 wins + points02 = 0 + points13 = 200 + } else { + // One-Two. Team 02 wins + points02 = 200 + points13 = 0 + } + + for (let player = 0; player < this._players.length; player++) { + let betPoints = (player === this._first ? 1 : -1) * this._players[player].bet + if (player % 2 === 0) { + points02 += betPoints + } else { + points13 += betPoints + } + } + + this._points02.push(points02) + this._points13.push(points13) + } + + private _hasRoundFinished (): boolean { + return this._players.filter(p => p.hand.length > 0).length === 1 || + (this._players[0].hand.length === 0 && this._players[2].hand.length === 0) || + (this._players[1].hand.length === 0 && this._players[3].hand.length === 0) + } + + /** + * Whether the given player has every given card in hand. + */ + _playerHasCards (player: Place, cards: Card[]): boolean { + return cards.every(c => this._players[player].hand.has(c)) + } + + newRound (): void { + this._discard = [] + this._state = initialGameState() + this._first = -1 + this._playing = -1 + this._lead = -1 + + let deck = fullDeck() + shuffle(deck) + let hands = splitChunk(deck, NUM_PLAYERS) + for (let i = 0; i < this._players.length; i++) { + this._players[i].newRound(hands[i]) + } + } + + private _startPlaying (): void { + this._state = GameState.PLAYING + this._playing = <Place>this._players.findIndex(p => p.hand.has(Card.MAHJONG)) + this._lead = this._playing + + for (let player = 0; player < NUM_PLAYERS; player++) { + for (let i = 0; i < 3; i++) { + let givenCard = this._players[player].cardsGiven[i] + this._players[(player - i + 3) % 4].hand.add(givenCard) + } + } + } + + get points02 (): number[] { + return this._points02 + } + + get points13 (): number[] { + return this._points13 + } + + ///////////// + // ACTIONS // + ///////////// + + /** A player give three cards. */ + giveCards (player: Place, cards: Card[]): boolean { + if (this._state !== GameState.GIVING_CARDS || !this._playerHasCards(player, cards) || !this._players[player].hasNextCards) { + return false + } + + cards.forEach(c => this._players[player].hand.remove(c)) + this._players[player].cardsGiven = cards + + if (this._players.every(p => p.cardsGiven.length > 0)) { + this._startPlaying() + } + + return true + } + + /** A player calls Tichu or Grand Tichu. */ + makeCall (player: Place, bet: number): boolean { + if (this._players[player].bet !== Bet.NONE) { + return false + } + + let res = true + if (bet === Bet.TICHU && (this._players[player].hand.length === 14 || (this._players[player].hand.length === 11 && this._state === GameState.GIVING_CARDS))) { + this._players[player].bet = Bet.TICHU + } else if (bet === Bet.GRAND && this._state === GameState.GIVING_CARDS && !this._players[player].hasNextCards) { + this._players[player].bet = Bet.GRAND + this._players[player].hasNextCards = true + } else { + res = false + } + + return res + } + + /** A player asks for the next cards (hasn't called grand tichu). */ + nextCards (player: Place): boolean { + if (this._state !== GameState.GIVING_CARDS || this._players[player].hasNextCards) { + return false + } + + this._players[player].hasNextCards = true + + return true + } + + /** A player plays cards. */ + playCards (player: Place, cards: Card[]): boolean { + if (!this._playerHasCards(player, cards) || this._state !== GameState.PLAYING) { + return false + } + + let played = new CardSet(cards) + + if (this._playing !== player && !(played.trick().isBomb() && this._discard.length > 0)) { + // It's not the player's turn, and the player is not playing a valid bomb (a bomb over a non-empty discard). + return false + } + + if (!played.canBePlayedOver(this._discard[this._discard.length - 1])) { + return false + } + + if (played.length === 1 && played.has(Card.PHOENIX)) { + let card = Card.PHOENIX + card.value = this._discard[this._discard.length - 1].toArray()[0].value + 0.5 + played = new CardSet([card]) + } + + // Play the card and update this._playing according to the card played. + if (played.length === 1 && played.has(Card.DOG)) { + this._players[player].tricks.add(Card.DOG) + this._playing = <Place>((player + 2) % 4) + } else { + this._discard.push(played) + this._playing = <Place>((player + 1) % 4) + } + + // Update this._lead and remove the cards from the players' hand. + this._lead = player + cards.forEach(c => this._players[player].hand.remove(c)) + + // Check if the player has finished, then if the game has finished. + if (this._players[player].hand.length === 0) { + if (this._first < 0) { + this._first = player + } else if (this._hasRoundFinished()) { + this._countPoints() + if (this.winner() < 0) { + this.newRound() + } + return true + } + } + + // Update this._playing according to players that has already finished. + while (this._players[this._playing].hand.length === 0) { + this._playing = <Place>((this._playing + 1) % 4) + } + + return true + } + + /** A player skips a turn. */ + skipTurn (player: Place): boolean { + if (this._state !== GameState.PLAYING || this._playing !== player || this._discard.length === 0) { + return false + } + + // Update this._playing until it finds a player that has cards or the leader. + do { + this._playing = <Place>((this._playing + 1) % 4) + } while (this._players[this._playing].hand.length === 0 && this._playing !== this._lead) + + if (this._playing === this._lead) { + // The leader is found and can take the cards in the discard, or give the dragon. + if (this._discard[this._discard.length - 1].has(Card.DRAGON)) { + this._state = GameState.GIVING_DRAGON + } else { + this._discard.forEach(d => this._players[this._lead].tricks.push(...d.toArray())) + this._discard = [] + // The leader might have no card left. + while (this._players[this._playing].hand.length === 0) { + this._playing = <Place>((this._playing + 1) % 4) + } + } + } + + return true + } + + /** A player gives the dragon. */ + giveDragon (player: Place, to: 0 | 1): boolean { + if (this._state !== GameState.GIVING_DRAGON || this._lead !== player) { + return false + } + + this._state = GameState.PLAYING + let absoluteTo = (player + (to === 0 ? 1 : 3)) % 4 + this._discard.forEach(d => this._players[absoluteTo].tricks.push(...d.toArray())) + this._discard = [] + while (this._players[this._playing].hand.length === 0) { + this._playing = <Place>((this._playing + 1) % 4) + } + + return true + } + + static fromObject (obj: GameSystemData): GameSystem { + let sys: GameSystem = Object.assign(new GameSystem(), obj) + + for (let i = 0; i < sys._players.length; i++) { + sys._players[i] = Player.fromObject(obj._players[i]) + } + + for (let i = 0; i < sys._discard.length; i++) { + sys._discard[i] = CardSet.fromObject(obj._discard[i]) + } + + return sys + } + + toObject (): GameSystemData { + let obj = new GameSystemData() + + obj._players = this._players.map(p => p.toObject()) + obj._points02 = this._points02 + obj._points13 = this._points13 + obj._discard = this._discard.map(t => t.toObject()) + obj._state = this._state + obj._first = this._first + obj._playing = this._playing + obj._lead = this._lead + obj._maxPoints = this._maxPoints + + return obj + } +} \ No newline at end of file diff --git a/api/games/_logic/Player.ts b/api/games/_logic/Player.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d351e092191d49121f057ddb8149087bfee7c8f --- /dev/null +++ b/api/games/_logic/Player.ts @@ -0,0 +1,58 @@ +import { Card, CardData } from './Card' +import { CardSet } from './CardSet' +import { Bet } from './Bet' + +export class PlayerData { + hand: CardData[] + bet: Bet + tricks: CardData[] + cardsGiven: CardData[] + hasNextCards: boolean +} + +export class Player { + hand: CardSet + bet: Bet + tricks: CardSet + cardsGiven: Card[] + hasNextCards: boolean + + constructor () { + this.hand = new CardSet() + this.bet = Bet.NONE + this.tricks = new CardSet() + this.cardsGiven = [] + this.hasNextCards = false + } + + newRound (cards: Card[]) { + this.hand.clear() + this.hand.push(...cards) + this.bet = Bet.NONE + this.tricks.clear() + this.cardsGiven = [] + this.hasNextCards = false + } + + static fromObject (obj: PlayerData): Player { + let player: Player = Object.assign(new Player(), obj) + + player.hand = CardSet.fromObject(obj.hand) + player.tricks = CardSet.fromObject(obj.tricks) + player.cardsGiven = obj.cardsGiven.map(Card.fromObject) + + return player + } + + toObject (): PlayerData { + let obj = new PlayerData() + + obj.bet = this.bet + obj.hasNextCards = this.hasNextCards + obj.hand = this.hand.toObject() + obj.tricks = this.tricks.toObject() + obj.cardsGiven = this.cardsGiven.map(c => c.toObject()) + + return obj + } +} \ No newline at end of file diff --git a/api/games/_logic/Trick.ts b/api/games/_logic/Trick.ts new file mode 100644 index 0000000000000000000000000000000000000000..9df25ed97e8a7204f0515134465bcb9acb337020 --- /dev/null +++ b/api/games/_logic/Trick.ts @@ -0,0 +1,144 @@ +import { Card } from './Card' + +export enum TrickKind { + INVALID, + SIMPLE, + DOUBLE, + TRIPLE, + FULL_HOUSE, + SUIT, + PAIR_SUIT, + BOMB +} + +export class Trick { + kind: TrickKind + _value: number + length: number + + /** + * @throws {Error} when length is not valid for the given `kind`. + */ + private constructor (kind: TrickKind, value: number, length: number = 0) { + this.kind = kind + this._value = value + this.length = length + + switch (kind) { + case TrickKind.INVALID: + break + case TrickKind.SIMPLE: + this.length = 1 + break + case TrickKind.DOUBLE: + this.length = 2 + break + case TrickKind.TRIPLE: + this.length = 3 + break + case TrickKind.FULL_HOUSE: + this.length = 5 + break + default: + if (length < 4 || 14 < length) { + throw new Error(`Trick constructor invalid arguments, kind: ${kind} length: ${length}`) + } + this.length = length + } + } + + isValid (): boolean { + return this.kind !== TrickKind.INVALID + } + + isBomb (): boolean { + return this.kind === TrickKind.BOMB + } + + get value (): number { + if (this.kind === TrickKind.BOMB) { + return this._value + 100 * this.length + } else { + return this._value + } + } + + // Constructors + + static get INVALID (): Trick { + return new Trick(TrickKind.INVALID, -1) + } + + static SIMPLE (value: number): Trick { + return new Trick(TrickKind.SIMPLE, value) + } + + static DOUBLE (value: number): Trick { + return new Trick(TrickKind.DOUBLE, value) + } + + static TRIPLE (value: number): Trick { + return new Trick(TrickKind.TRIPLE, value) + } + + static FULL_HOUSE (value: number): Trick { + return new Trick(TrickKind.FULL_HOUSE, value) + } + + static SUIT (ncards: number, value: number): Trick { + return new Trick(TrickKind.SUIT, value, ncards) + } + + static PAIR_SUIT (nCards: number, value: number): Trick { + return new Trick(TrickKind.PAIR_SUIT, value, nCards) + } + + static BOMB (nCards: number, value: number): Trick { + return new Trick(TrickKind.BOMB, value, nCards) + } + + /** + * Find out the kind of trick from a sorted array of cards. + * The cards must be sorted from the higgest value to the lowest. + */ + static fromSortedArray (cards: Card[]): Trick { + if (cards.length === 0) return Trick.INVALID + switch (cards.length) { + case 1: + let card = cards[0] + return Trick.SIMPLE(card.isPhoenix() ? -1 : card.value) + case 2: + return (cards[0].value === cards[1].value) ? Trick.DOUBLE(cards[0].value) : Trick.INVALID + case 3: + return (cards[0].value === cards[1].value && cards[0].value === cards[2].value) + ? Trick.TRIPLE(cards[0].value) + : Trick.INVALID + case 4: + if (cards[0].value === cards[1].value && cards[2].value === cards[3].value) { + if (cards[1].value === cards[2].value && cards.every(c => !c.isPhoenix())) { + return Trick.BOMB(4, cards[1].value) + } else if (cards[1].value - 1 === cards[2].value) { + return Trick.PAIR_SUIT(4, cards[0].value) + } + } + return Trick.INVALID + case 5: + if (cards[0].value === cards[1].value && cards[0].value === cards[2].value && cards[3].value === cards[4].value) { + return Trick.FULL_HOUSE(cards[0].value) + } else if (cards[0].value === cards[1].value && cards[2].value === cards[3].value && cards[2].value === cards[4].value) { + return Trick.FULL_HOUSE(cards[2].value) + } + /* FALLTHROUGH */ + default: + if (cards.length % 2 === 0 && cards.every((c, i, cards) => cards[0].value - c.value === Math.floor(i / 2))) { + return Trick.PAIR_SUIT(cards.length, cards[0].value) + } else if (cards.every((c, i, cards) => cards[0].value - c.value === i)) { + return cards.every(c => c.color === cards[0].color && !c.isPhoenix()) + ? Trick.BOMB(cards.length, cards[0].value) + : Trick.SUIT(cards.length, cards[0].value) + } else { + return Trick.INVALID + } + } + } +} diff --git a/api/games/_logic/deck.ts b/api/games/_logic/deck.ts new file mode 100644 index 0000000000000000000000000000000000000000..67a383f565c83943cb5af2e41bce48a231fa1d94 --- /dev/null +++ b/api/games/_logic/deck.ts @@ -0,0 +1,36 @@ +import { Card, Color } from './Card' + +/** All the not-special cards. */ +export function partialDeck (): Card[] { + let deck: Card[] = [] + for (let color of [Color.BLUE, Color.GREEN, Color.RED, Color.BLACK]) { + for (let value = 2; value < 15; value++) { + deck.push(new Card(color, value)) + } + } + return deck +} + +/** The deck of all cards. */ +export function fullDeck (): Card[] { + return [Card.DOG, Card.MAHJONG, Card.PHOENIX, Card.DRAGON].concat(partialDeck()) +} + +/** Shuffles an array. */ +export function shuffle<T> (arr: T[]): T[] { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]] + } + return arr +} + +/** Split an array into n chunks of equal length. */ +export function splitChunk<T> (arr: T[], n: number): T[][] { + let chunkLength = Math.floor(arr.length / n) + let res: T[][] = [] + for (let i = 0; i < n; i++) { + res.push(arr.slice(i * chunkLength, (i + 1) * chunkLength)) + } + return res +} diff --git a/api/games/cards.js b/api/games/cards.js deleted file mode 100644 index 972d49e9170014a0502604fc52ab40ad0c6f1703..0000000000000000000000000000000000000000 --- a/api/games/cards.js +++ /dev/null @@ -1,517 +0,0 @@ -import SortedArraySet from 'collections/sorted-set' - -export const Color = { - DOG: 0, - MAHJONG: 1, - PHOENIX: 2, - BLUE: 3, - GREEN: 4, - RED: 5, - BLACK: 6, - DRAGON: 7, - MAX_COLOR: 8 -} - -const TrickKind = { - INVALID: 0, - SIMPLE: 1, - DOUBLE: 2, - TRIPLE: 3, - FULL_HOUSE: 4, - SUIT: 5, - PAIR_SUIT: 6, - BOMB: 7 -} - -/** - * Compare two cards. Used for display. - * - * @param {Card} c1 - * @param {Card} c2 - * @return {number} Negative when c1 is before c2, - * null when they're the same, - * positive when c2 is before c1. - */ -export function compareCards (c1, c2) { - if (c1.color < 2 && c2.color < 2) return c2.color - c1.color - else if (c1.color < 2) return 1 - else if (c2.color < 2) return -1 - else if (c1.color === Color.DRAGON && c2.color === Color.DRAGON) return 0 - else if (c1.color === Color.DRAGON) return -1 - else return c2.value - c1.value + (c2.color - c1.color) / Color.MAX_COLOR -} - -/** - * Whether two cards are the same. - * - * @param {Card} c1 - * @param {Card} c2 - * @return {boolean} - */ -export function cardsEqual (c1, c2) { - return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || (c1.color === c2.color && c1.value === c2.value) -} - -/** - * Whether two cards are of the same value. - * - * @param {Card} c1 - * @param {Card} c2 - * @return {boolean} - */ -export function cardsValueEqual (c1, c2) { - return (c1.color === Color.PHOENIX && c2.color === Color.PHOENIX) || c1.value === c2.value -} - -export class Card { - - /** - * Default constructor. - * - * @param {number} color - * @param {number} value - */ - constructor (color, value) { - - /** - * The color of the card. - * - * @type {number} - */ - this.color = color - - /** - * The value of the card. - * - * @type {number} - */ - this.value = value - } - - isPhoenix () { - return this.color === Color.PHOENIX - } - - /** - * The number of points this Card is worth. - * - * @return {number} - */ - points () { - if (this.color === Color.PHOENIX) return -25 - else if (this.color === Color.DRAGON) return 25 - else if (this.value === 5) return 5 - else if (this.value === 10 || this.value === 13) return 10 - else return 0 - } - - /** - * The name of the card. - * - * @return {string} - */ - name () { - switch (this.color) { - case Color.DOG: return 'dog' - case Color.MAHJONG: return 'mahjong' - case Color.PHOENIX: return 'phoenix' - case Color.BLUE: return this.value + ' of pagoda' - case Color.GREEN: return this.value + ' of jade' - case Color.RED: return this.value + ' of star' - case Color.BLACK: return this.value + ' of sword' - case Color.DRAGON: return 'dragon' - default: return 'INVALID CARD' - } - } - - static get DOG () { - return new Card(Color.DOG, 0) - } - - static get MAHJONG () { - return new Card(Color.MAHJONG, 1) - } - - static get PHOENIX () { - return new Card(Color.PHOENIX, 0) - } - - static get DRAGON () { - return new Card(Color.DRAGON, 15) - } - - static fromObject (obj) { - return Object.assign(new Card(-1, -1), obj) - } - - toObject () { - return Object.assign({}, this) - } -} - -export class Trick { - - /** - * Default constructor. Prefer using the other constructors to create Trick objects. - * - * It will throw an - * - * @param {number} kind - * @param {number} value - * @param {number} [length=0] Should be an integer. - * - * @throws {Error} when length is not valid for the given `kind`. - */ - constructor (kind, value, length) { - if (length == null) { - length = 0 - } - - /** @type {number} */ - this.kind = kind - - /** @type {number} */ - this._value = value - - /** @type {number} */ - this.length = length - - switch (kind) { - case TrickKind.INVALID: - break - case TrickKind.SIMPLE: - this.length = 1 - break - case TrickKind.DOUBLE: - this.length = 2 - break - case TrickKind.TRIPLE: - this.length = 3 - break - case TrickKind.FULL_HOUSE: - this.length = 5 - break - default: - if (length < 4 || 14 < length) { - throw new Error(`Trick constructor invalid arguments, kind: ${kind} length: ${length}`) - } - this.length = length - } - } - - /** - * @returns {boolean} - */ - isValid () { - return this.kind !== TrickKind.INVALID - } - - /** - * @returns {boolean} - */ - isBomb () { - return this.kind === TrickKind.BOMB - } - - /** - * @returns {number} - */ - get value () { - if (this.kind === TrickKind.BOMB) { - return this._value + 100 * this.length - } else { - return this._value - } - } - - /** - * @returns {string} - */ - name () { - let kindName - switch (this.kind) { - case TrickKind.INVALID: - kindName = 'Invalid trick' - break - case TrickKind.SIMPLE: - kindName = 'High card' - break - case TrickKind.DOUBLE: - kindName = 'Pair' - break - case TrickKind.TRIPLE: - kindName = 'Triple' - break - case TrickKind.FULL_HOUSE: - kindName = 'Full' - break - case TrickKind.SUIT: - kindName = 'Suit' - break - case TrickKind.PAIR_SUIT: - kindName = 'Suit of pairs' - break - case TrickKind.BOMB: - kindName = 'Bomb' - break - } - return `${kindName} (${this._value}, ${this.length})` - } - - // Constructors - - /** - * @return {Trick} - * @constructor - */ - static get INVALID () { - return new Trick(TrickKind.INVALID, -1) - } - - /** - * @return {Trick} - * @constructor - */ - static SIMPLE (value) { - return new Trick(TrickKind.SIMPLE, value) - } - - /** - * @return {Trick} - * @constructor - */ - static DOUBLE (value) { - return new Trick(TrickKind.DOUBLE, value) - } - - /** - * @return {Trick} - * @constructor - */ - static TRIPLE (value) { - return new Trick(TrickKind.TRIPLE, value) - } - - /** - * @return {Trick} - * @constructor - */ - static FULL_HOUSE (value) { - return new Trick(TrickKind.FULL_HOUSE, value) - } - - /** - * @return {Trick} - * @constructor - */ - static SUIT (ncards, value) { - return new Trick(TrickKind.SUIT, value, ncards) - } - - /** - * @return {Trick} - * @constructor - */ - static PAIR_SUIT (ncards, value) { - return new Trick(TrickKind.PAIR_SUIT, value, ncards) - } - - /** - * @return {Trick} - * @constructor - */ - static BOMB (ncards, value) { - return new Trick(TrickKind.BOMB, value, ncards) - } - - /** - * Find out the kind of trick from a sorted array of cards. - * The cards must be sorted from the higgest value to the lowest. - * - * @param {Card[]} cards - * @returns {Trick} - */ - static fromSortedArray (cards) { - if (cards.length === 0) return Trick.INVALID - switch (cards.length) { - case 1: - let card = cards[0] - return Trick.SIMPLE(card.isPhoenix() ? -1 : card.value) - case 2: - return (cards[0].value === cards[1].value) ? Trick.DOUBLE(cards[0].value) : Trick.INVALID - case 3: - return (cards[0].value === cards[1].value && cards[0].value === cards[2].value) - ? Trick.TRIPLE(cards[0].value) - : Trick.INVALID - case 4: - if (cards[0].value === cards[1].value && cards[2].value === cards[3].value) { - if (cards[1].value === cards[2].value && cards.every(c => !c.isPhoenix())) { - return Trick.BOMB(4, cards[1].value) - } else if (cards[1].value - 1 === cards[2].value) { - return Trick.PAIR_SUIT(cards[0].value) - } - } - return Trick.INVALID - case 5: - if (cards[0].value === cards[1].value && cards[0].value === cards[2].value && cards[3].value === cards[4].value) { - return Trick.FULL_HOUSE(cards[0].value) - } else if (cards[0].value === cards[1].value && cards[2].value === cards[3].value && cards[2].value === cards[4].value) { - return Trick.FULL_HOUSE(cards[2].value) - } - /* FALLTHROUGH */ - default: - if (cards.length % 2 === 0 && cards.every((c, i, cards) => cards[0].value - c.value === Math.floor(i / 2))) { - return Trick.PAIR_SUIT(cards[0].value) - } else if (cards.every((c, i, cards) => cards[0].value - c.value === i)) { - return cards.every(c => c.color === cards[0].color && !c.isPhoenix()) - ? Trick.BOMB(cards.length, cards[0].value) - : Trick.SUIT(cards.length, cards[0].value) - } else { - return Trick.INVALID - } - } - } -} - -export class CardSet { - - /** - * Default constructor. - * - * @param {Card[]} [cards=[]] Initial set of cards. - */ - constructor (cards) { - if (cards == null) { - cards = [] - } - - /** - * The underlying card array. It is sorted. - * - * @type {Card[]} - * @private - */ - this._data = new SortedArraySet(cards, cardsEqual, compareCards, () => Card.DOG) - - /** - * The trick corresponding to this CardSet, or null if it has not be computed yet. - * It is used to not compute the trick every time `CardSet.trick` is called. - * - * @type {?Trick} - * @private - */ - this._cached_trick = null - } - - // SET METHODS - - clear () { - this._data.clear() - this._cached_trick = null - } - - /** - * Adds the given cards to the set. - * - * @param {Card|Card[]|CardSet} cards - */ - add (cards) { - if (cards.constructor === Card) { - cards = [cards] - } else if (cards.constructor === CardSet) { - cards = cards._data.toArray() - } - - this._data.push(...cards) - this._cached_trick = null - } - - /** - * Whether this CardSet has the given card. - * - * @param {Card} card - * @return {Boolean} - */ - has (card) { - return this._data.has(card) - } - - get length () { - return this._data.length - } - - /** - * Remove a card from this CardSet. - * - * @param {Card} card - * @return {boolean} Whether the given card has been removed. - */ - remove (card) { - this._cached_trick = null - return this._data.remove(card) - } - - /** - * This CardSet as an Array. - * - * @return {Card[]} - */ - toArray () { - return this._data.toArray() - } - - // CARDS METHODS - - /** - * Whether this CardSet can be played over the `other` CardSet. - * - * @param {CardSet} other - * @returns {boolean} - */ - canBePlayedOver (other) { - if (other._data.length === 0 || (this.trick().isBomb() && !other.trick().isBomb())) { - // Rien n'a été joué. - // ou - // On a joué une bombe sur une combinaison normale. - return true - } else if ((!other.trick().isBomb() && this._data.length !== other._data.length) || (!this.trick().isBomb() && other.trick().isBomb())) { - // On joue sur une combinaison normale, et notre combinaison n'a pas le même nombre de cartes que l'autre. - // ou - // On joue une combinaison normale sur une bombe. - return false - } else if (this.trick().length === 1 && this.has(Card.PHOENIX)) { - return !other.has(Card.DRAGON) - } else { - return this.trick().value - other.trick().value > 0 - } - } - - /** - * The number of points this CardSet is worth. - * - * @return {number} - */ - points () { - return this._data.reduce((p, c) => p + c.points(), 0) - } - - /** - * The trick corresponding to this CardSet. - * - * @returns {Trick} - */ - trick () { - if (this._cached_trick == null) { - this._cached_trick = Trick.fromSortedArray(this._data.toArray()) - } - return this._cached_trick - } - - static fromObject (obj) { - return new CardSet(obj.map(Card.fromObject)) - } - - toObject () { - return this.toArray().map(c => c.toObject()) - } -} diff --git a/api/games/games.ts b/api/games/games.ts index 1b1c1a7db31adc90836f80efb2d809fc6cd78e5e..8cfe53c5150fadccb70b2c67e3c02e0187e7d019 100644 --- a/api/games/games.ts +++ b/api/games/games.ts @@ -3,8 +3,7 @@ import SimpleSchema from 'simpl-schema' import { ValidatedMethod } from 'meteor/mdg:validated-method' import { loggedInMixin, schemaMixin } from '../mixins' import { Meteor } from 'meteor/meteor' -import { Card } from './cards' -import { Bet, GameSystem } from './logic' +import { Bet, Card, GameSystem, GameSystemData, Place } from './logic' export const Games = new Mongo.Collection('games') @@ -19,7 +18,7 @@ export function notInGameMixin<A, R> (method: ValidatedMethod<A, R>) { export function inGameMixin<A, R> (method: ValidatedMethod<A, R>) { let run = method.run - method.run = function (...params: any[]) { + method.run = function (this: { game: any, userId: string }, ...params: any[]) { this.game = Games.findOne({'players.id': this.userId}) if (this.game == null) throw new Meteor.Error('not_in_game') this.game.sys = GameSystem.fromObject(this.game.sys) @@ -28,41 +27,38 @@ export function inGameMixin<A, R> (method: ValidatedMethod<A, R>) { return method } -const PlayerSchema = new SimpleSchema({ - id: String, - position: {type: Number, allowedValues: [0, 1, 2, 3]} -}) - -const GameSchema = new SimpleSchema({ - name: String, - players: {type: Array, defaultValue: [], optional: true}, - 'players.$': PlayerSchema, - sys: {type: GameSystem, defaultValue: new GameSystem(), optional: true} -}) - -interface Player { +export class PlayerPosition { id: string - position: number + position: Place } -interface Game { +export class Game { _id: string name: string - players: Player[] + players: PlayerPosition[] sys: GameSystem } +class GameData { + _id: string + name: string + players: PlayerPosition[] + sys: GameSystemData +} + export const newGame = new ValidatedMethod<{ name: string }, string>({ name: 'Game.new', mixins: [schemaMixin, loggedInMixin, notInGameMixin], validate: new SimpleSchema({ name: String }), - run (game) { - if (Games.find(game).count() === 0) { - let gameClean: Game = GameSchema.clean(game) as Game - gameClean.sys = gameClean.sys.toObject() - return Games.insert(gameClean) + run ({name}) { + if (Games.find({name}).count() === 0) { + return Games.insert({ + name, + players: [], + sys: new GameSystem().toObject() + }) } else { throw new Meteor.Error('name_taken') } @@ -76,14 +72,14 @@ export const joinGame = new ValidatedMethod<{ _id: string }, void>({ _id: String }), run ({_id}) { - let game = Games.findOne({_id}) as Game + let game: GameData | null = Games.findOne({_id}) as GameData | null if (game == null) throw new Meteor.Error('game_not_found') if (game.players.length === 4) throw new Meteor.Error('game_full') - game.players.push({id: this.userId, position: game.players.length}) + game.players.push({id: this.userId, position: <Place>(game.players.length)}) if (game.players.length === 4) { - game.sys = GameSystem.fromObject(game.sys) - game.sys.newRound() - game.sys = game.sys.toObject() + let sys = GameSystem.fromObject(game.sys) + sys.newRound() + game.sys = sys.toObject() } Games.update({_id}, {$set: game}) } @@ -96,14 +92,14 @@ export const giveCards = newGameMethod('giveCards', new SimpleSchema({ 'cards.$': Card }), 'cards') -export const playCards = new ValidatedMethod({ +export const playCards = new ValidatedMethod<{ cards: Card[] }, void>({ name: 'Game.playCards', mixins: [schemaMixin, loggedInMixin, inGameMixin], validate: new SimpleSchema({ cards: [Card] }), run ({cards}) { - let player = this.game.players.findIndex(p => p.id === this.userId) + let player = this.game.players.findIndex((p: PlayerPosition) => p.id === this.userId) if (!this.game.sys.playCards(player, cards)) { throw new Meteor.Error('playCards') diff --git a/api/games/logic.js b/api/games/logic.js deleted file mode 100644 index 643fe21adb18dd8e73b1435d0b7a15aedf82ac8f..0000000000000000000000000000000000000000 --- a/api/games/logic.js +++ /dev/null @@ -1,646 +0,0 @@ -import { Card, CardSet, Color } from './cards' - -export const NUM_PLAYERS = 4 -//export const NUM_CARDS = 56 - -export const Bet = { - NONE: 0, - TICHU: 100, - GRAND: 200 -} - -export const GameState = { - GIVING_CARDS: 1, - PLAYING: 2, - GIVING_DRAGON: 3, - - initial () { - return 1 - } -} - -/** - * All the not-special cards. - * @returns {Card[]} - */ -export function partialDeck () { - let deck = [] - for (let color of [Color.BLUE, Color.GREEN, Color.RED, Color.BLACK]) { - for (let value = 2; value < 15; value++) { - deck.push(new Card(color, value)) - } - } - return deck -} - -/** - * The deck of all cards. - * - * @return {Card[]} - */ -export function fullDeck () { - return [Card.DOG, Card.MAHJONG, Card.PHOENIX, Card.DRAGON].concat(partialDeck()) -} - -/** - * Shuffles an array. - * - * @param {Array} arr - * @return {Array} - */ -export function shuffle (arr) { - for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [arr[i], arr[j]] = [arr[j], arr[i]] - } - return arr -} - -/** - * Split an array into n chunks of equal length - * - * @param {*[]} arr - * @param {number} n - */ -export function splitChunk (arr, n) { - let chunkLength = arr.length / n - let res = [] - for (let i = 0; i < n; i++) { - res.push(arr.slice(i * chunkLength, (i + 1) * chunkLength)) - } - return res -} - -export class Player { - constructor () { - /** - * @type {CardSet} - */ - this.hand = new CardSet() - - /** - * @type {number} - */ - this.bet = Bet.NONE - - /** - * The cards that the player has earned. - * - * @type {CardSet} - */ - this.tricks = new CardSet() - - /** - * @type {Card[]} - */ - this.cardsGiven = [] - - /** - * True if the player has seen all its cards. - * - * @type {boolean} - */ - this.hasNextCards = false - } - - newRound (cards) { - this.hand.clear() - this.hand.add(cards) - this.bet = Bet.NONE - this.tricks.clear() - this.cardsGiven = [] - this.hasNextCards = false - } - - static fromObject (obj) { - let player = Object.assign(new Player(), obj) - - player.hand = CardSet.fromObject(player.hand) - player.tricks = CardSet.fromObject(player.tricks) - - for (let i = 0; i < player.cardsGiven.length; i++) { - player.cardsGiven[i] = Card.fromObject(player.cardsGiven[i]) - } - - return player - } - - toObject () { - let obj = Object.assign({}, this) - - obj.hand = obj.hand.toObject() - obj.tricks = obj.tricks.toObject() - - for (let i = 0; i < obj.cardsGiven.length; i++) { - obj.cardsGiven[i] = obj.cardsGiven[i].toObject() - } - - return obj - } -} - -export class GameSystem { - constructor () { - /** - * The array of players. - * The (n+1)th player is at the right of the nth player, modulo the number of players. - * - * @type {Player[]} - * @protected - */ - this._players = [] - - /** - * The number of points the team formed by the 0th and the 2th player has earned during each round. - * - * @type {number[]} - * @private - */ - this._points02 = [] - - /** - * The number of points the team formed by the 1th and the 3th player has earned during each round. - * - * @type {number[]} - * @private - */ - this._points13 = [] - - /** - * The discard, as a stack of the played tricks. - * - * @type {CardSet[]} - * @protected - */ - this._discard = [] - - /** - * The state of the game. - * - * @type {number} - * @protected - */ - this._state = GameState.initial() - - /** - * The first player that has finished the round, or -1 if there is none. - * - * @type {number} - * @private - */ - this._first = -1 - - /** - * The player that has to play, or -1 if there is none (GameState != PLAYING) - * - * @type {number} - * @protected - */ - this._playing = -1 - - /** - * The player that will get the discard if the others skip turns, or -1 if there is none. - * - * @type {number} - * @protected - */ - this._lead = -1 - - /** - * The point cap to reach for the game to end. - * - * @type {number} - * @private - */ - this._maxPoints = 100 - - for (let i = 0; i < NUM_PLAYERS; i++) { - this._players.push(new Player()) - } - } - - /////////// - // UTILS // - /////////// - - /** - * Whether the game is over. - * - * @return {number} -1 if the game is not over - * 0 if the team 02 wins - * 1 if the team 13 wins - */ - winner () { - let points02 = this._points02.reduce((s, p) => s + p) - let points13 = this._points13.reduce((s, p) => s + p) - - if ((points02 >= this._maxPoints || points13 >= this._maxPoints) && points02 !== points13) { - return points02 > points13 ? 0 : 1 - } else { - return -1 - } - } - - /** - * Counts the points of each player and add them to the points of the round. - * A new round must start after a call to this method. - * - * @private - */ - _countPoints () { - let unfinished = this._players.filter(p => p.hand.length > 0) - let points02 = 0 - let points13 = 0 - - if (unfinished.length === 1) { - let last = unfinished[0] - - if (last % 2 === 0) { - // Last player is in team 02 - points13 += this._players[last].hand.points() - } else { - points02 += this._players[last].hand.points() - } - - if (this._first % 2 === 0) { - // First player is in team 02 - points02 += this._players[last].tricks.points() - } else { - points13 += this._players[last].tricks.points() - } - - this._players[last].tricks.clear() - points02 += this._players[0].tricks.points() + this._players[2].tricks.points() - points13 += this._players[1].tricks.points() + this._players[3].tricks.points() - } else if (this._players[0].hand.length > 0) { - // One-Two. Team 13 wins - points02 = 0 - points13 = 200 - } else { - // One-Two. Team 02 wins - points02 = 200 - points13 = 0 - } - - for (let player = 0; player < this._players.length; player++) { - let betPoints = (player === this._first ? 1 : -1) * this._players[player].bet - if (player % 2 === 0) { - points02 += betPoints - } else { - points13 += betPoints - } - } - - this._points02.push(points02) - this._points13.push(points13) - } - - _hasRoundFinished () { - return this._players.filter(p => p.hand.length > 0).length === 1 || - (this._players[0].hand.length === 0 && this._players[2].hand.length === 0) || - (this._players[1].hand.length === 0 && this._players[3].hand.length === 0) - } - - /** - * Whether the given player has every given card in hand. - * - * @param {number} player either 0, 1, 2 or 3 - * @param {Card[]} cards - * @return {boolean} - * @private - */ - _playerHasCards (player, cards) { - return cards.every(c => this._players[player].hand.has(c)) - } - - newRound () { - this._discard = [] - this._state = GameState.initial() - this._first = -1 - this._playing = -1 - this._lead = -1 - - let deck = fullDeck() - shuffle(deck) - let hands = splitChunk(deck, NUM_PLAYERS) - for (let i = 0; i < this._players.length; i++) { - this._players[i].newRound(hands[i]) - } - } - - _startPlaying () { - this._state = GameState.PLAYING - this._playing = this._players.findIndex(p => p.hand.has(Card.MAHJONG)) - this._lead = this._playing - - for (let player = 0; player < NUM_PLAYERS; player++) { - for (let i = 0; i < 3; i++) { - let givenCard = this._players[player].cardsGiven[i] - this._players[(player - i + 3) % 4].hand.add(givenCard) - } - } - } - - get points02 () { - return this._points02 - } - - get points13 () { - return this._points13 - } - - ///////////// - // ACTIONS // - ///////////// - - /** - * A player give three cards. - * - * @param {number} player either 0, 1, 2 or 3 - * @param {Card[]} cards must have 3 elements - * @return {boolean} True if the cards can be given - */ - giveCards (player, cards) { - if (this._state !== GameState.GIVING_CARDS || !this._playerHasCards(player, cards) || !this._players[player].hasNextCards) { - return false - } - - cards.forEach(c => this._players[player].hand.remove(c)) - this._players[player].cardsGiven = cards - - if (this._players.every(p => p.cardsGiven.length > 0)) { - this._startPlaying() - } - - return true - } - - /** - * A player calls Tichu or Grand Tichu. - * - * @param {number} player either 0, 1, 2 or 3 - * @param {number} bet either 100 or 200 - * @return {boolean} True if the call can be made - */ - makeCall (player, bet) { - if (this._players[player].bet !== Bet.NONE) { - return false - } - - let res = true - if (bet === Bet.TICHU && (this._players[player].hand.length === 14 || (this._players[player].hand.length === 11 && this._state === GameState.GIVING_CARDS))) { - this._players[player].bet = Bet.TICHU - } else if (bet === Bet.GRAND && this._state === GameState.GIVING_CARDS && !this._players[player].hasNextCards) { - this._players[player].bet = Bet.GRAND - this._players[player].hasNextCards = true - } else { - res = false - } - - return res - } - - /** - * A player asks for the next cards (hasn't called grand tichu). - * - * @param {number} player either 0, 1, 2 or 3 - * @return {boolean} Whether the player can have the next cards. - */ - nextCards (player) { - if (this._state !== GameState.GIVING_CARDS || this._players[player].hasNextCards) { - return false - } - - this._players[player].hasNextCards = true - - return true - } - - /** - * A player plays cards. - * - * @param {number} player either 0, 1, 2 or 3 - * @param {Card[]} cards - * @return {boolean} Whether the cards can be played. - */ - playCards (player, cards) { - if (!this._playerHasCards(player, cards) || this._state !== GameState.PLAYING) { - return false - } - - let played = new CardSet(cards) - - if (this._playing !== player && !(played.trick().isBomb() && this._discard.length > 0)) { - // It's not the player's turn, and the player is not playing a valid bomb (a bomb over a non-empty discard). - return false - } - - if (!played.canBePlayedOver(this._discard[this._discard.length - 1])) { - return false - } - - if (played.length === 1 && played.has(Card.PHOENIX)) { - let card = Card.PHOENIX - card.value = this._discard[this._discard.length - 1].value + 0.5 - played = new CardSet([card]) - } - - // Play the card and update this._playing according to the card played. - if (played.length === 1 && played.has(Card.DOG)) { - this._players[player].tricks.add(Card.DOG) - this._playing = (player + 2) % 4 - } else { - this._discard.push(played) - this._playing = (player + 1) % 4 - } - - // Update this._lead and remove the cards from the players' hand. - this._lead = player - cards.forEach(c => this._players[player].hand.remove(c)) - - // Check if the player has finished, then if the game has finished. - if (this._players[player].hand.length === 0) { - if (this._first < 0) { - this._first = player - } else if (this._hasRoundFinished()) { - this._countPoints() - if (this.winner() < 0) { - this.newRound() - } - return true - } - } - - // Update this._playing according to players that has already finished. - while (this._players[this._playing].hand.length === 0) { - this._playing = (this._playing + 1) % 4 - } - - return true - } - - /** - * A player skips a turn. - * - * @param {number} player either 0, 1, 2 or 3 - * @return {boolean} Whether the player can skip a turn. - */ - skipTurn (player) { - if (this._state !== GameState.PLAYING || this._playing !== player || this._discard.length === 0) { - return false - } - - // Update this._playing until it finds a player that has cards or the leader. - do { - this._playing = (this._playing + 1) % 4 - } while (this._players[this._playing].hand.length === 0 && this._playing !== this._lead) - - if (this._playing === this._lead) { - // The leader is found and can take the cards in the discard, or give the dragon. - if (this._discard[this._discard.length - 1].has(Card.DRAGON)) { - this._state = GameState.GIVING_DRAGON - } else { - this._discard.forEach(d => this._players[this._lead].tricks.add(d)) - this._discard = [] - // The leader might have no card left. - while (this._players[this._playing].hand.length === 0) { - this._playing = (this._playing + 1) % 4 - } - } - } - - return true - } - - /** - * A player gives the dragon. - * - * @param {number} player either 0, 1, 2 or 3 - * @param {number} to which player to give the dragon. Either 0 (player to the right) or 1 (to the left). - * @return {boolean} Whether the dragon can be given. - */ - giveDragon (player, to) { - if (this._state !== GameState.GIVING_DRAGON || this._lead !== player) { - return false - } - - this._state = GameState.PLAYING - to = (player + (to === 0 ? 1 : 3)) % 4 - this._discard.forEach(d => this._players[to].tricks.add(d)) - this._discard = [] - while (this._players[this._playing].hand.length === 0) { - this._playing = (this._playing + 1) % 4 - } - - return true - } - - static fromObject (obj) { - let sys = Object.assign(new GameSystem(), obj) - - for (let i = 0; i < sys._players.length; i++) { - sys._players[i] = Player.fromObject(sys._players[i]) - } - - for (let i = 0; i < sys._discard.length; i++) { - sys._discard[i] = CardSet.fromObject(sys._discard[i]) - } - - return sys - } - - toObject () { - let obj = Object.assign({}, this) - - for (let i = 0; i < obj._players.length; i++) { - obj._players[i] = obj._players[i].toObject() - } - - for (let i = 0; i < obj._discard.length; i++) { - obj._discard[i] = obj._discard[i].toObject() - } - - return obj - } -} - -export class GamePov { - /** - * A game from the point of view of a player placed at a given place. - * - * @param {GameSystem} game - * @param {number} place either 0, 1, 2 or 3 - */ - constructor (game, place) { - /** - * Underlying game. - * - * @type {GameSystem} - * @private - */ - this._game = game - - /** - * The place of the player. - * - * @type {number} - * @private - */ - this._place = place - } - - get hand () { - return this._game._players[this._place].hand.toArray() - } - - get givingCards () { - return this._game._state === GameState.GIVING_CARDS - } - - get givingDragon () { - return this._game._state === GameState.GIVING_DRAGON && this._game._playing === this._place - } - - isPlaying (place) { - place = this._absolutePlace(place) - return this._game._playing === ((this._place + place) % 4) - } - - hasLead (place) { - place = this._absolutePlace(place) - return this._game._lead === ((this._place + place) % 4) - } - - get discard () { - if (this._game._discard.length > 0) { - return this._game._discard[this._game._discard.length - 1].toArray() - } else { - return null - } - } - - bet (place) { - place = this._absolutePlace(place) - return this._game._players[place].bet - } - - numCards (place) { - place = this._absolutePlace(place) - return this._game._players[place].hand.length - } - - /** - * From a place relative to this._place returns the absolute place in the game. - * - * @param {number} [rel] either 0 (west), 1 (north), 2 (east) or null (south) - * @return {number} - * @private - */ - _absolutePlace (rel) { - if (rel == null) { - return this._place - } else { - return (this._place - rel + 3) % 4 - } - } -} diff --git a/api/games/logic.ts b/api/games/logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3f10e91f2e27e8af60f853e53fb2eb7acfdcd83 --- /dev/null +++ b/api/games/logic.ts @@ -0,0 +1,8 @@ +export * from './_logic/Bet' +export * from './_logic/Card' +export * from './_logic/CardSet' +export * from './_logic/deck' +export * from './_logic/GamePov' +export * from './_logic/GameSystem' +export * from './_logic/Player' +export * from './_logic/Trick' diff --git a/api/mixins.js b/api/mixins.ts similarity index 59% rename from api/mixins.js rename to api/mixins.ts index 8b0dd4e3f96cc9f95a422c35bb46ac99f20c7a45..ae2e7ae7f5e0f5e5a41f5966306f7605b3bc1107 100644 --- a/api/mixins.js +++ b/api/mixins.ts @@ -1,11 +1,12 @@ import { Meteor } from 'meteor/meteor' +import { ValidatedMethod } from 'meteor/mdg:validated-method' -export const schemaMixin = method => { +export function schemaMixin<A, R> (method: ValidatedMethod<A, R>) { method.validate = method.validate.validator({ clean: true }) return method } -export const loggedInMixin = method => { +export function loggedInMixin<A, R> (method: ValidatedMethod<A, R>) { let run = method.run method.run = function () { if (!this.userId) throw new Meteor.Error('not_logged_in') diff --git a/client/main.ts b/client/main.ts index 5a64fe36d39111184160e0f265342c63c9412bf7..909a0b715928c3ae25076fe11d132222cff35e41 100644 --- a/client/main.ts +++ b/client/main.ts @@ -1,8 +1,8 @@ import { Meteor } from 'meteor/meteor' import { render } from 'react-dom' -import '../startup/accounts' -import routes from '../startup/routes' +import '../api/accounts' +import routes from '../ui/routes' Meteor.startup(() => { let $app = document.createElement('div') diff --git a/package-lock.json b/package-lock.json index a4841c2748a3c78fcccac0fd883cd1994adabba6..b0bad44b4f4b7bb81348665f5f12363490d39313 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,11 @@ "to-fast-properties": "^2.0.0" } }, + "@types/collections": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/collections/-/collections-5.0.0.tgz", + "integrity": "sha512-hP3yPM7ePW89pKcVCnCTnzSaKdr7QQaz/fGfj8YUSIbWAmvNM/QVRWXpmh7bh19u2x006FPWBHow6ZX3zOL5lA==" + }, "@types/connect": { "version": "3.4.32", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", @@ -155,6 +160,11 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.11.7.tgz", "integrity": "sha512-yOxFfkN9xUFLyvWaeYj90mlqTJ41CsQzWKS3gXdOMOyPVacUsymejKxJ4/pMW7exouubuEeZLJawGgcNGYlTeg==" }, + "@types/pixi.js": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@types/pixi.js/-/pixi.js-4.8.1.tgz", + "integrity": "sha512-aKAnT85e8fGJvHjLAVJoJuYg3S1vZxenCEwkj/kvGCBpcxZtxHi/okC3u23rJAsnoo/keBbfWyUIdijSIhwjSg==" + }, "@types/prop-types": { "version": "15.5.6", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.6.tgz", @@ -652,6 +662,11 @@ } } }, + "bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha1-DGwfq+KyPRcXPZpht7cJPrnhdp4=" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -765,6 +780,11 @@ "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", "dev": true }, + "earcut": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.1.3.tgz", + "integrity": "sha512-AxdCdWUk1zzK/NuZ7e1ljj6IGC+VAdC3Qb7QQDsXpfNrc5IM8tL9nNXUmEGE6jRHTfZ10zhzRhtDmWVsR5pd3A==" + }, "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -882,6 +902,11 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" }, + "ismobilejs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-0.4.1.tgz", + "integrity": "sha1-Gl8SbHD+05yT2jgPpiy65XI+fcI=" + }, "istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -1669,6 +1694,11 @@ } } }, + "mini-signals": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mini-signals/-/mini-signals-1.2.0.tgz", + "integrity": "sha1-RbCAE8X65RokqhqTXNMXye1yHXQ=" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -2879,6 +2909,11 @@ "wrappy": "1" } }, + "parse-uri": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-uri/-/parse-uri-1.0.0.tgz", + "integrity": "sha1-KHLcwi8aeXrN4Vg9igrClVLdrCA=" + }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2906,6 +2941,33 @@ "eventemitter3": "^3.1.0" } }, + "pixi-gl-core": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/pixi-gl-core/-/pixi-gl-core-1.1.4.tgz", + "integrity": "sha1-i0tcQzsx5Bm8N53FZc4bg1qRs3I=" + }, + "pixi.js": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-4.8.2.tgz", + "integrity": "sha512-OHA3Q3wwxRJXkVWALVuiUcUqQZd5p0rQF9ikCvOmux3A6Lxb5S61v4PMEAVgR3+1auZekbv/GNHCxDGFCQSi8g==", + "requires": { + "bit-twiddle": "^1.0.2", + "earcut": "^2.1.3", + "eventemitter3": "^2.0.0", + "ismobilejs": "^0.4.0", + "object-assign": "^4.0.1", + "pixi-gl-core": "^1.1.4", + "remove-array-items": "^1.0.0", + "resource-loader": "^2.1.1" + }, + "dependencies": { + "eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=" + } + } + }, "prop-types": { "version": "15.6.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", @@ -2989,11 +3051,25 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", "integrity": "sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==" }, + "remove-array-items": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remove-array-items/-/remove-array-items-1.0.0.tgz", + "integrity": "sha1-B79CyzMvTPboXq2DteToltIyayE=" + }, "resolve-pathname": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz", "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg==" }, + "resource-loader": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/resource-loader/-/resource-loader-2.1.1.tgz", + "integrity": "sha512-jRMGYUfa4AGk9ib45Wxc93lobhQVoiCUAUkWqsbb/fhGPge97YT1S8aC0xBEQpolMsrdmB3o7SH8VmIEvIDOLA==", + "requires": { + "mini-signals": "^1.1.1", + "parse-uri": "^1.0.0" + } + }, "schedule": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/schedule/-/schedule-0.5.0.tgz", diff --git a/package.json b/package.json index 37d7a18a863ba82c75de58d2eeb502fc948ede78..4175dd19606b216fe5d89736ad151f49fcb2e5a5 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ }, "dependencies": { "@babel/runtime": "^7.0.0", + "@types/collections": "^5.0.0", "@types/history": "^4.7.0", "@types/meteor": "^1.4.22", + "@types/pixi.js": "^4.8.1", "@types/react-dom": "^16.0.9", "@types/react-router": "^4.0.32", "@types/react-router-dom": "^4.3.1", @@ -20,6 +22,7 @@ "history": "^4.7.2", "meteor-node-stubs": "^0.4.1", "phaser": "^3.14.0", + "pixi.js": "^4.8.2", "react": "^16.5.2", "react-dom": "^16.5.2", "react-router": "^4.3.1", @@ -29,7 +32,7 @@ "meteor": { "mainModule": { "client": "client/main.ts", - "server": "server/main.js" + "server": "server/main.ts" } }, "devDependencies": { diff --git a/server/main.js b/server/main.ts similarity index 100% rename from server/main.js rename to server/main.ts diff --git a/tsconfig.json b/tsconfig.json index 80d7d97d5404f03461a434225a2e02a60b7a96e6..e4ca855b1ca981fea01ef3efd3b811eefacdf4f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,12 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", + "module": "commonjs", "removeComments": true, "sourceMap": true, "downlevelIteration": true, "jsx": "react", + "lib": ["dom", "es5", "es6"], "alwaysStrict": true, "noImplicitAny": true, diff --git a/typings/collections.d.ts b/typings/collections.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6c76b9a5eb715172457af81efa738d6c948c673 --- /dev/null +++ b/typings/collections.d.ts @@ -0,0 +1,16 @@ +declare module 'collections/sorted-array-set' { + import SortedSet = require('collections/sorted-set') + + export class SortedArraySet<T> extends SortedSet<T> { + constructor ( + values?: T[], + equals?: (a: T, b: T) => boolean, + compare?: (a: T, b: T) => number, + getDefault?: any + ) + + length: number + + toArray (): T[] + } +} \ No newline at end of file diff --git a/typings/globals.d.ts b/typings/globals.d.ts index ed1c1bb3b0dfa48a566379ea9cb83c4dbe826b50..43a64f4923fdcbd3a7db0a0934987707cf76f696 100644 --- a/typings/globals.d.ts +++ b/typings/globals.d.ts @@ -1,7 +1,5 @@ -interface Function { - name: string -} - +//* interface Array<T> { findIndex (predicate: (value: T, index: number, obj: Array<T>) => boolean, thisArg?: any): number } +// */ diff --git a/typings/mdg_validated-method.d.ts b/typings/mdg_validated-method.d.ts index c3cbe6d1506689f283acade3cb50688a3cab108a..f7d11b91d7080e0d64d75f09bd7fb8a422ad402b 100644 --- a/typings/mdg_validated-method.d.ts +++ b/typings/mdg_validated-method.d.ts @@ -3,20 +3,22 @@ declare module 'meteor/mdg:validated-method' { module MeteorValidatedMethod { export class ValidatedMethod<A, R> { - constructor (options: ValidatedMethodOptions<A, R>); + constructor (options: ValidatedMethodOptions<A, R>) - call (options?: A, cb?: (err: Error, res: R) => void): void; + validate: any - run (this: any, opts: A): R; + call (options?: A, cb?: (err: Error, res: R) => void): void + + run (this: any, opts: A): R } interface ValidatedMethodOptions<A, R> { - name: string; - mixins?: ((method: ValidatedMethod<A, R>) => ValidatedMethod<A, R>)[]; - validate: any; - applyOptions?: any; + name: string + mixins?: ((method: ValidatedMethod<A, R>) => ValidatedMethod<A, R>)[] + validate: any + applyOptions?: any - run (this: any, opts: A): R; + run (this: any, opts: A): R } } } \ No newline at end of file diff --git a/typings/react-meteor-data.d.ts b/typings/react-meteor-data.d.ts index ef495556f398c215b519fdad87f1e23cd4aa3c28..d27c6e54106d3591d193ae4e6cfc08bebb16a1c6 100644 --- a/typings/react-meteor-data.d.ts +++ b/typings/react-meteor-data.d.ts @@ -1,5 +1,12 @@ declare module 'meteor/react-meteor-data' { + class Match { + params: { [key: string]: string } + isExact: boolean + path: string + url: string + } + function withTracker<T> ( - getProps: () => T, - ): (component: React.ComponentClass<T> | React.StatelessComponent<T>) => React.ComponentClass<T>; + getProps: (query: { match: Match }) => T, + ): (component: React.ComponentClass<T> | React.StatelessComponent<T>) => React.ComponentClass<T> } diff --git a/ui/_tichu_game/CardArray.ts b/ui/_tichu_game/CardArray.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/ui/_tichu_game/GameApplication.ts b/ui/_tichu_game/GameApplication.ts new file mode 100644 index 0000000000000000000000000000000000000000..04975fdb77588e001b3a7d33334eeb583b11ae98 --- /dev/null +++ b/ui/_tichu_game/GameApplication.ts @@ -0,0 +1,34 @@ +import * as PIXI from 'pixi.js' +import { ResponsiveApplication } from './ResponsiveApplication' +import { partialDeck } from '../../api/games/_logic/deck' +import { Game } from '../../api/games/games' +import { Place } from '../../api/games/logic' + +export class GameApplication extends ResponsiveApplication { + constructor (el: HTMLDivElement) { + super() + el.appendChild(this.view) + this.loadTextures() + } + + loadTextures (): void { + let normalCards = partialDeck().map(c => ({ + name: `${c.color} ${c.value}`, + url: `/cards/${c.color}${c.value}.png` + })) + PIXI.loader + .add(normalCards) + .add('dragon', '/cards/dragon.png') + .add('phoenix', '/cards/phoenix.png') + .add('dog', '/cards/dog.png') + .add('mahjong', '/cards/mahjong.png') + .load(this.draw) + } + + draw (): void { + let cat = new PIXI.Sprite(PIXI.loader.resources['dragon'].texture) + this.stage.addChild(cat) + } + + update (game: Game, place: Place) {} +} diff --git a/ui/_tichu_game/Hand.ts b/ui/_tichu_game/Hand.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/ui/_tichu_game/ResponsiveApplication.ts b/ui/_tichu_game/ResponsiveApplication.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3e1e3a064807e833e66cdbe851721f8f394347b --- /dev/null +++ b/ui/_tichu_game/ResponsiveApplication.ts @@ -0,0 +1,13 @@ +import * as PIXI from 'pixi.js' + +export class ResponsiveApplication extends PIXI.Application { + constructor (options?: PIXI.ApplicationOptions) { + super(options) + this.renderer.view.style.position = "absolute" + this.renderer.view.style.display = "block" + this.renderer.view.style.padding = "0" + this.renderer.view.style.margin = "0" + this.renderer.autoResize = true + this.renderer.resize(window.innerWidth, window.innerHeight) + } +} \ No newline at end of file diff --git a/ui/components/accounts_ui_wrapper.tsx b/ui/components/accounts_ui_wrapper.tsx index 19394c2dea0950ab4fed81157d196ef0c5656a25..47c28522cda8c58c6c56734c43aa4735b0e14f7e 100644 --- a/ui/components/accounts_ui_wrapper.tsx +++ b/ui/components/accounts_ui_wrapper.tsx @@ -4,7 +4,7 @@ import { Blaze } from 'meteor/blaze' export default class AccountsUIWrapper extends React.Component<{}> { private view: Blaze.View - private readonly spanRef: React.RefObject<any> + private readonly spanRef: React.RefObject<HTMLSpanElement> constructor () { super({}) diff --git a/ui/components/game_entry.tsx b/ui/components/game_entry.tsx index 6311e9f7dfec03e7da03c64e1a38976c4ba78d42..e7a8f8151e11a494e6af68c457b5c3e7bb62ef5c 100644 --- a/ui/components/game_entry.tsx +++ b/ui/components/game_entry.tsx @@ -1,10 +1,19 @@ import * as React from 'react' import { Link } from 'react-router-dom' +import { Game, joinGame } from '../../api/games/games' + +export default class GameEntry extends React.Component<{ game: Game }> { + onClick (event) { + joinGame.call({_id: this.props.game._id}, (err) => { + if (err) console.error(err) + }) + } -export default class GameEntry extends React.Component<{ game: {} }> { render () { return ( - <li><Link to={'/games/' + this.props.game._id}>{this.props.game.name}</Link></li> + <li> + <Link onClick={this.onClick.bind(this)} to={'/games/' + this.props.game._id}>{this.props.game.name}</Link> + </li> ) } } diff --git a/ui/components/player_entry.jsx b/ui/components/player_entry.jsx deleted file mode 100644 index 15e2f0b8714df57dbb1e2a1f1b20a158496a9999..0000000000000000000000000000000000000000 --- a/ui/components/player_entry.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react' - -export default class GameEntry extends React.Component { - render () { - return ( - <li>{this.props.player.id}</li> - ) - } -} diff --git a/ui/components/player_entry.tsx b/ui/components/player_entry.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8147c49fbf6fd294ef5565a4e4ac35cdded53fc7 --- /dev/null +++ b/ui/components/player_entry.tsx @@ -0,0 +1,10 @@ +import * as React from 'react' +import { PlayerPosition } from '../../api/games/games' + +export default class GameEntry extends React.Component<{ player: PlayerPosition }> { + render () { + return ( + <li>{this.props.player.id}</li> + ) + } +} diff --git a/ui/pages/game.jsx b/ui/pages/game.tsx similarity index 57% rename from ui/pages/game.jsx rename to ui/pages/game.tsx index 409233a5fe196a7c7909d04155d44b017e5b01e3..b12c0a9824a3a9b19b233eeccfa8003c97ecefaf 100644 --- a/ui/pages/game.jsx +++ b/ui/pages/game.tsx @@ -1,23 +1,40 @@ import { Meteor } from 'meteor/meteor' -import React from 'react' +import * as React from 'react' import { withTracker } from 'meteor/react-meteor-data' -import { Games, joinGame } from '../../api/games/games.ts' -import PlayerEntry from '../components/player_entry.jsx' -import TichuGame from '../tichu_game' -import { GameSystem } from '../../api/games/logic.js' +import { Game, Games } from '../../api/games/games' +import PlayerEntry from '../components/player_entry' +import { GameSystem } from '../../api/games/logic' +import { GameApplication } from '../tichu_game' -class Game extends React.Component { - renderGame () { +class GameContainer extends React.Component<{ game: Game }> { + private game: GameApplication | null + private readonly gameRef: React.RefObject<HTMLDivElement> + + constructor (props: Readonly<{ game: Game }>) { + super(props) + this.gameRef = React.createRef() + } + + renderGame (): void { + if (this.gameRef.current == null) return if (this.game == null) { - this.game = new TichuGame() + this.game = new GameApplication(this.gameRef.current) + } + let p = this.props.game.players.find(p => p.id === Meteor.userId()) + if (p != null) { + this.game.update(this.props.game, p.position) } - this.game.update(this.props.game, this.props.game.players.find(p => p.id === Meteor.userId()).position) + } + + componentDidMount () { + this.renderGame() } render () { if (this.props.game != null && this.props.game.players.length === 4) { this.renderGame() return (<div> + <div ref={this.gameRef}/> <table> <thead> <tr> @@ -55,15 +72,9 @@ class Game extends React.Component { } export default withTracker(function ({match}) { - if (this.called == null) { - joinGame.call({_id: match.params.id}, (err, res) => { - if (err) console.log('Error while joining game:', err) - }) - this.called = true - } - let game = Games.find({_id: match.params.id}).fetch()[0] + let game: any = Games.find({_id: match.params.id}).fetch()[0] if (game != null) { game.sys = GameSystem.fromObject(game.sys) } return {game} -})(Game) +})(GameContainer) diff --git a/ui/pages/game_list.tsx b/ui/pages/game_list.tsx index 55877468f65d6cb9f11637731bf9e90ca5eebee8..aedb832ff26aef3668c1e7d79949162c06b2bb6b 100644 --- a/ui/pages/game_list.tsx +++ b/ui/pages/game_list.tsx @@ -1,13 +1,13 @@ import * as React from 'react' import { withTracker } from 'meteor/react-meteor-data' import GameEntry from '../components/game_entry' -import { Games, newGame } from '../../api/games/games' +import { Game, Games, newGame } from '../../api/games/games' interface SubmitEvent extends Event { target: HTMLFormElement } -class GameList extends React.Component<{ games: Array<{}> }> { +class GameList extends React.Component<{ games: Array<Game> }> { onSubmit (event: SubmitEvent) { event.preventDefault() diff --git a/startup/routes.tsx b/ui/routes.tsx similarity index 80% rename from startup/routes.tsx rename to ui/routes.tsx index f1f8e016490188a9b02c090e7623cc6c0a34099c..63a25923f71a0df11826ae1c4f5cee19eb14e24f 100644 --- a/startup/routes.tsx +++ b/ui/routes.tsx @@ -2,9 +2,9 @@ import * as React from 'react' import { Route, Router, Switch } from 'react-router' import createBrowserHistory from 'history/createBrowserHistory' -import GameListPage from '../ui/pages/game_list' -import GamePage from '../ui/pages/game.jsx' -import IndexPage from '../ui/pages/index' +import GameListPage from './pages/game_list' +import GamePage from './pages/game.jsx' +import IndexPage from './pages/index' const browserHistory = createBrowserHistory() diff --git a/ui/tichu_game.js b/ui/tichu_game.tests.js similarity index 94% rename from ui/tichu_game.js rename to ui/tichu_game.tests.js index 576483d44ff89d33ae9f04e2843b2e72ff3b5287..68511d38cfaedf1cd32e97c7aecdee3b093c5903 100644 --- a/ui/tichu_game.js +++ b/ui/tichu_game.tests.js @@ -7,30 +7,7 @@ import { skipTurn } from '../api/games/games' import { Bet, GamePov } from '../api/games/logic' -import { CardSet, Color, Trick } from '../api/games/cards' - -function cardName ({value, color}) { - switch (color) { - case Color.DRAGON: - return 'dragon' - case Color.PHOENIX: - return 'phoenix' - case Color.MAHJONG: - return 'mahjong' - case Color.DOG: - return 'dog' - case Color.BLACK: - return 'black' + value - case Color.BLUE: - return 'blue' + value - case Color.GREEN: - return 'green' + value - case Color.RED: - return 'red' + value - default: - return '' - } -} +import { CardSet, Color, Trick } from '../api/games/logic' function playerBackground (data, player) { if (data.givingDragon) return {backgroundColor: '#55c'} diff --git a/ui/tichu_game.ts b/ui/tichu_game.ts new file mode 100644 index 0000000000000000000000000000000000000000..53ebdcc20df2acc6c00c9e7c55d9ab3b95d0e708 --- /dev/null +++ b/ui/tichu_game.ts @@ -0,0 +1 @@ +export * from './_tichu_game/GameApplication'