diff --git a/src/common/components/admin/CardMigrationPanel.tsx b/src/common/components/admin/CardMigrationPanel.tsx index 4d31c226..38a9945e 100644 --- a/src/common/components/admin/CardMigrationPanel.tsx +++ b/src/common/components/admin/CardMigrationPanel.tsx @@ -1,15 +1,17 @@ -import { compact, countBy, noop, omit } from 'lodash'; +import { compact, countBy, isEqual, noop, omit } from 'lodash'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Paper } from '@material-ui/core'; import * as React from 'react'; import * as w from '../../types'; +import * as g from '../../guards'; import { TYPE_EVENT } from '../../constants'; -import { expandKeywords, getSentencesFromInput, parseBatch } from '../../util/cards'; +import { md5 } from '../../util/common'; +import { getCardAbilities, getCardSentences, parseBatch } from '../../util/cards'; import * as firebase from '../../util/firebase'; import { collection as coreSet } from '../../store/cards'; -import CardGrid from '../cards/CardGrid'; import { Card } from '../card/Card'; import { DIALOG_PAPER_BASE_STYLE } from '../RouterDialog'; +import CardGrid from '../cards/CardGrid'; interface PreviewReport { statistics: { @@ -17,7 +19,7 @@ interface PreviewReport { numChanged: number numUnchanged: number } - changedCards: Array + changedCards: Array } interface CardMigrationPanelProps { @@ -113,11 +115,17 @@ class CardMigrationPanel extends React.PureComponent {
Old JS ({card.parserV}):
{card.oldAbilities.join('\n')}
-
New JS ({parserVersion}):{' '} +
New JS ({parserVersion?.version}):{' '}
                         {card.parseErrors.length > 0 ? {card.parseErrors.join('\n')} : card.newAbilities.join('\n')}
                       
+ {!isEqual(new Set(card.integrity.map(i => i.hmac)), new Set((card.oldIntegrity || []).map(i => i.hmac))) && ( +
+
Old Integrity:
{JSON.stringify(card.oldIntegrity, null, 2)}
+
New Integrity:
{JSON.stringify(card.integrity, null, 2)}
+
+ )}
))} @@ -157,25 +165,33 @@ class CardMigrationPanel extends React.PureComponent { const objectCards = cards.filter((c) => c.type !== TYPE_EVENT); const eventCards = cards.filter((c) => c.type === TYPE_EVENT); - const objectCardSentences = compact(objectCards.flatMap(c => c.text ? getSentencesFromInput(c.text) : []).map((s) => expandKeywords(s))); - const eventCardSentences = compact(eventCards.flatMap(c => c.text ? getSentencesFromInput(c.text) : []).map((s) => expandKeywords(s))); + const objectCardSentences = compact(objectCards.flatMap(getCardSentences)); + const eventCardSentences = compact(eventCards.flatMap(getCardSentences)); const objectCardParseResults = await parseBatch(objectCardSentences, 'object'); const eventCardParseResults = await parseBatch(eventCardSentences, 'event'); const parseResults = [...objectCardParseResults, ...eventCardParseResults]; - const errors = compact(parseResults.map((r) => r.result.error)); + const errors = compact(parseResults.map((r) => (r.result as w.FailedParseResult).error)); + const integrityHashes: w.Hashes[] = compact(parseResults.flatMap((r) => g.isSuccessfulParseResult(r.result) ? r.result.hashes : [])); const parseResultForSentence = Object.fromEntries(parseResults.map((({ sentence, result }) => [sentence, result]))); const changedCards = ( cards + .filter((c) => !c.id.startsWith('builtin/')) .map((c) => ({ ...c, - parseErrors: compact(getSentencesFromInput(c.text || '').map((s) => expandKeywords(s)).map((s) => parseResultForSentence[s]?.error)), - oldAbilities: (c.command ? [c.command].flat() : c.abilities || []), - newAbilities: compact(getSentencesFromInput(c.text || '').map((s) => expandKeywords(s)).map((s) => parseResultForSentence[s]?.js)) + parseErrors: compact(getCardSentences(c).map(s => parseResultForSentence[s]).map(r => g.isFailedParseResult(r) ? r.error : undefined)), + oldAbilities: getCardAbilities(c), + newAbilities: compact(getCardSentences(c).map(s => parseResultForSentence[s]).map(r => g.isSuccessfulParseResult(r) ? r.js : undefined)), + integrity: integrityHashes.filter((hash) => getCardSentences(c).map(md5).includes(hash.input)), + oldIntegrity: c.integrity })) - .filter((c) => c.oldAbilities.join('\n') !== c.newAbilities.join('\n') || c.parseErrors.length > 0) + .filter((c) => + c.oldAbilities.join('\n') !== c.newAbilities.join('\n') + || !isEqual(new Set(c.integrity.map(i => i.hmac)), new Set((c.oldIntegrity || []).map(i => i.hmac))) + || c.parseErrors.length > 0 + ) ); this.setState({ @@ -218,7 +234,7 @@ class CardMigrationPanel extends React.PureComponent { if (card.parseErrors.length === 0) { const originalParserV = card.parserV || 'unknown'; const migratedCard: w.CardInStore = { - ...omit(card, ['parseErrors', 'oldAbilities', 'newAbilities']), + ...omit(card, ['parseErrors', 'oldAbilities', 'newAbilities', 'oldIntegrity']) as w.CardInStore, ...(card.type === TYPE_EVENT ? { command: card.newAbilities } : { abilities: card.newAbilities }), parserV: parserVersion!.version, originalParserV, diff --git a/src/common/components/admin/MiscUtilitiesPanel.tsx b/src/common/components/admin/MiscUtilitiesPanel.tsx index 6733cf45..4d917bd2 100644 --- a/src/common/components/admin/MiscUtilitiesPanel.tsx +++ b/src/common/components/admin/MiscUtilitiesPanel.tsx @@ -3,10 +3,15 @@ import { uniqBy } from 'lodash'; import * as React from 'react'; import * as w from '../../types'; +import { checkValidityOfIntegrityHashes, getCardAbilities, validateIntegrityHashesAreComplete } from '../../util/cards'; import * as firebase from '../../util/firebase'; +import { collection as coreSet } from '../../store/cards'; interface MiscUtilitiesPanelProps { + cards: w.CardInStore[] + sets: w.Set[] games: Record + reloadData: () => void } interface MiscUtilitiesPanelState { @@ -20,18 +25,24 @@ class MiscUtilitiesPanel extends React.Component + + + +
           {log.map((line, i) => 
{line}
)} @@ -71,6 +82,38 @@ class MiscUtilitiesPanel extends React.Component => { + const { cards, sets } = this.props; + this.log('ALL CARDS'); + await this.detectIntegrityProblemsForCards(cards); + + this.log('~~~'); + this.log('BUILT-IN CARDS'); + await this.detectIntegrityProblemsForCards(coreSet); + + for (const set of sets) { + this.log('~~~'); + this.log(`SET: ${set.name} (${set.id}) by ${set.metadata.authorName} (excluding ${set.cards.filter((c) => c.id.startsWith('builtin/')).length} builtins)`); + await this.detectIntegrityProblemsForCards(set.cards.filter((c) => !c.id.startsWith('builtin/'))); + } + } + + private detectIntegrityProblemsForCards = async (cards: w.CardInStore[]): Promise => { + this.log(`Total # cards: ${cards.length}`); + const numCardsWithNoIntegrity = cards.filter((c) => !c.integrity?.length).length; + const numCardsWithNoAbilities = cards.filter((c) => !c.integrity?.length && getCardAbilities(c).length === 0).length; + this.log(`Cards without integrity hashes at all: ${numCardsWithNoIntegrity} (of which ${numCardsWithNoAbilities} have no abilities)`); + + const cardsWithIncompleteIntegrityHashes: w.CardInStore[] = cards.filter((c) => !validateIntegrityHashesAreComplete(c, this.log)); + this.log(`Cards with missing/incomplete integrity hashes: ${cardsWithIncompleteIntegrityHashes.length}: ${cardsWithIncompleteIntegrityHashes.map((c) => c.id).join(', ')}`); + + const cardsWithHashesToVerify: w.CardInStore[] = cards.filter((card) => !cardsWithIncompleteIntegrityHashes.map((c) => c.id).includes(card.id)); + const { validCards, invalidCards, statistics: { numValidHashes, numInvalidHashes } } = await checkValidityOfIntegrityHashes(cardsWithHashesToVerify); + this.log(`${numValidHashes}/${numValidHashes + numInvalidHashes} hashes are valid`); + this.log(`Cards with invalid hashes: ${invalidCards.length}`); + this.log(`${validCards.length !== cards.length ? '* ' : ''}Valid cards: ${validCards.length}`); + } } export default MiscUtilitiesPanel; diff --git a/src/common/components/card/Sentence.tsx b/src/common/components/card/Sentence.tsx index 1f802f3f..ef082573 100644 --- a/src/common/components/card/Sentence.tsx +++ b/src/common/components/card/Sentence.tsx @@ -2,6 +2,7 @@ import { flatMap, times } from 'lodash'; import * as React from 'react'; import * as w from '../../types'; +import * as g from '../../guards'; import { keywordsInSentence, splitSentences } from '../../util/cards'; import { id } from '../../util/common'; @@ -37,7 +38,7 @@ export default class Sentence extends React.Component { {times(numInitialNewlines, (i) =>
)} diff --git a/src/common/components/card/StatusIcon.tsx b/src/common/components/card/StatusIcon.tsx index 284a7ca6..d7a3e783 100644 --- a/src/common/components/card/StatusIcon.tsx +++ b/src/common/components/card/StatusIcon.tsx @@ -2,13 +2,12 @@ import Icon from '@material-ui/core/Icon'; import * as React from 'react'; import { PARSER_URL } from '../../constants'; -import * as w from '../../types'; import { expandKeywords } from '../../util/cards'; import Tooltip from '../Tooltip'; interface StatusIconProps { text: string - result: w.ParseResult | null + result: { js?: string, parsed?: boolean, error?: string } | null } export default class StatusIcon extends React.Component { @@ -27,7 +26,8 @@ export default class StatusIcon extends React.Component { style={{ fontSize: '0.7em', verticalAlign: 'top', - color: isParsed ? 'green' : (result.error ? 'red' : 'black')} + color: isParsed ? 'green' : (result.error ? 'red' : 'black') + } } > {isParsed ? 'code' : (result.error ? 'error_outline' : 'more_horiz')} diff --git a/src/common/components/card/Word.tsx b/src/common/components/card/Word.tsx index 7f634568..ea7bb847 100644 --- a/src/common/components/card/Word.tsx +++ b/src/common/components/card/Word.tsx @@ -14,7 +14,7 @@ export default class Word extends React.PureComponent { public render(): JSX.Element { const { word, keywords, result } = this.props; - if (((result?.unrecognizedTokens) || []).includes(word.toLowerCase())) { + if ((((result as w.FailedParseResult | null)?.unrecognizedTokens) || []).includes(word.toLowerCase())) { return ( {' '}{word} diff --git a/src/common/components/cards/CardPreview.tsx b/src/common/components/cards/CardPreview.tsx index 39f0a2f1..b32fd482 100644 --- a/src/common/components/cards/CardPreview.tsx +++ b/src/common/components/cards/CardPreview.tsx @@ -84,10 +84,10 @@ export default class CardPreview extends React.Component { stats={this.stats} cardStats={this.stats} flavorText={this.props.flavorText} - text={this.props.sentences.map((s, i) => )} + text={this.props.sentences.map((s, i) => )} rawText={this.props.sentences.map((s) => s.sentence).join('. ')} source={{ type: 'user' }} - showSpinner={sentences.some((s) => !s.result.js && !s.result.error)} + showSpinner={sentences.some((s) => s === null)} scale={2.5} onSpriteClick={onSpriteClick} overrideContainerStyles={{ padding: 0 }} diff --git a/src/common/components/cards/CardTextField.tsx b/src/common/components/cards/CardTextField.tsx index e572b194..26dac529 100644 --- a/src/common/components/cards/CardTextField.tsx +++ b/src/common/components/cards/CardTextField.tsx @@ -41,7 +41,7 @@ export default class CardTextField extends React.Component - (s.result.suggestions || []).map((suggestion) => + ((s.result as w.FailedParseResult).suggestions || []).map((suggestion) => ({ original: s.sentence.trim(), suggestion: contractKeywords(suggestion) @@ -65,8 +65,8 @@ export default class CardTextField extends React.Component -
-
+
+
@@ -101,15 +101,15 @@ export default class CardTextField extends React.Component { if (this.textSuggestions.length > 0) { return ( -
+
Did you mean: {this.textSuggestions.map(({ original, suggestion }) => ( - - ))} ? + + ))} ?
); } diff --git a/src/common/components/help/DictionaryDialog.tsx b/src/common/components/help/DictionaryDialog.tsx index 7bedbba5..04e51e6c 100644 --- a/src/common/components/help/DictionaryDialog.tsx +++ b/src/common/components/help/DictionaryDialog.tsx @@ -62,15 +62,15 @@ export default class DictionaryDialog extends React.Component<{ history: History const dictionary: w.Dictionary = await getDictionaryData(); // eslint-disable-next-line react/no-did-mount-set-state this.setState((state) => ({ - dictionary: {...state.dictionary, ...dictionary} + dictionary: { ...state.dictionary, ...dictionary } })); } public shouldComponentUpdate(nextProps: { history: History }, nextState: DictionaryState): boolean { return ( !isEqual(nextState, this.state) || - // can't do `!== this.props.history.location.pathname` because History gets mutated by react-router -AN - (nextProps.history.location.pathname !== this.state.currentPath) + // can't do `!== this.props.history.location.pathname` because History gets mutated by react-router -AN + (nextProps.history.location.pathname !== this.state.currentPath) ); } @@ -107,8 +107,8 @@ export default class DictionaryDialog extends React.Component<{ history: History get dictionaryTerms(): string[] { return Object.keys(this.dictionaryDefinitions) - .filter((t) => t.includes(this.state.searchText) && t !== '"' && t !== '\'') - .sort(); + .filter((t) => t.includes(this.state.searchText) && t !== '"' && t !== '\'') + .sort(); } get dictionaryDefinitions(): Record> { const { dictionary } = this.state; @@ -121,8 +121,8 @@ export default class DictionaryDialog extends React.Component<{ history: History get thesaurusTerms(): string[] { return Object.keys(this.thesaurusExamples) - .filter((t) => t.includes(this.state.searchText)) - .sort(); + .filter((t) => t.includes(this.state.searchText)) + .sort(); } get thesaurusExamples(): Record { return this.state.dictionary.examplesByNode || {}; @@ -130,9 +130,9 @@ export default class DictionaryDialog extends React.Component<{ history: History get keywordsTerms(): string[] { return Object.keys(allKeywords()) - .filter((t) => t.includes(this.state.searchText)) - .sort() - .map(capitalize); + .filter((t) => t.includes(this.state.searchText)) + .sort() + .map(capitalize); } public render(): JSX.Element { @@ -163,7 +163,7 @@ export default class DictionaryDialog extends React.Component<{ history: History private selectTerm = (term: string, callback: () => void = noop) => { this.setState( - {[`${this.currentTab}Term`]: term} as Pick, + { [`${this.currentTab}Term`]: term } as Pick, callback ); } @@ -171,7 +171,7 @@ export default class DictionaryDialog extends React.Component<{ history: History private checkHash = () => { const hash = getHash(this.props.history); if (hash && hash !== this.hash) { - const [ type, term ] = hash.split(/:(.+)/); // https://stackoverflow.com/a/4607799 + const [type, term] = hash.split(/:(.+)/); // https://stackoverflow.com/a/4607799 const tabIdx = 'dtk'.indexOf(type); this.setState({ tabIdx }, () => { @@ -227,19 +227,19 @@ export default class DictionaryDialog extends React.Component<{ history: History const tabStyle = { color: 'white', fontSize: '0.85em' }; return ( -
+
- + @@ -266,11 +266,11 @@ export default class DictionaryDialog extends React.Component<{ history: History const examples = uniq(examplesByTerm[term].map(this.cleanupExample)); return (
- Examples + Examples
    {examples.map((example) =>
  • - {example}.  + {example}. 
  • )}
@@ -284,7 +284,7 @@ export default class DictionaryDialog extends React.Component<{ history: History if (this.state.showDefinitions) { return (
- Definitions + Definitions
    {definitions.map((d) =>
  1. @@ -296,7 +296,7 @@ export default class DictionaryDialog extends React.Component<{ history: History ); } else if (definitions) { return ( -
    +
    [Show {definitions.length} definition(s) (Advanced feature)] @@ -311,7 +311,7 @@ export default class DictionaryDialog extends React.Component<{ history: History const definition = allKeywords()[term]; return (
    - Definition + Definition

    {definition.endsWith(',') ? `${definition} [...] .` : definition}

    @@ -325,8 +325,8 @@ export default class DictionaryDialog extends React.Component<{ history: History
    {this.renderTabs()} -
    -
    +
    +
    -
    - +
    + {this.renderTitle()} -
    +
    {this.renderPage()}
    -
    +
    ); } diff --git a/src/common/containers/Admin.tsx b/src/common/containers/Admin.tsx index c12303e1..4b9b9d66 100644 --- a/src/common/containers/Admin.tsx +++ b/src/common/containers/Admin.tsx @@ -114,7 +114,7 @@ class Admin extends React.PureComponent { {tab === 'statistics' && } {tab === 'cardMigration' && } {tab === 'undraftableCards' && } - {tab === 'miscUtilities' && } + {tab === 'miscUtilities' && }
    ) : } diff --git a/src/common/containers/Creator.tsx b/src/common/containers/Creator.tsx index 18ddc60a..f906986e 100644 --- a/src/common/containers/Creator.tsx +++ b/src/common/containers/Creator.tsx @@ -34,6 +34,7 @@ export interface CreatorStateProps { textSource: w.TextSource flavorText: string sentences: w.Sentence[] + integrity: w.Hashes[] spriteID: string attack: number speed: number @@ -89,6 +90,7 @@ export function mapStateToProps(state: w.State): CreatorStateProps { cost: state.creator.cost, spriteID: state.creator.spriteID, sentences: state.creator.sentences, + integrity: state.creator.integrity, text: state.creator.text, textSource: state.creator.textSource, flavorText: state.creator.flavorText, diff --git a/src/common/guards.ts b/src/common/guards.ts index de5d789f..4fca72bd 100644 --- a/src/common/guards.ts +++ b/src/common/guards.ts @@ -40,3 +40,11 @@ export function isCardVisible(target: w.PossiblyObfuscatedCard): target is w.Car export function isPassiveAbility(ability: w.Ability): ability is w.PassiveAbility { return (ability as w.PassiveAbility).unapply !== undefined; } + +export function isSuccessfulParseResult(parseResult: w.ParseResult): parseResult is w.SuccessfulParseResult { + return (parseResult as w.SuccessfulParseResult).js !== undefined; +} + +export function isFailedParseResult(parseResult: w.ParseResult): parseResult is w.FailedParseResult { + return (parseResult as w.FailedParseResult).error !== undefined; +} diff --git a/src/common/reducers/creator.ts b/src/common/reducers/creator.ts index 15789990..dcaab179 100644 --- a/src/common/reducers/creator.ts +++ b/src/common/reducers/creator.ts @@ -1,18 +1,19 @@ -import { fromPairs } from 'lodash'; +import { fromPairs, uniqBy } from 'lodash'; +import * as w from '../types'; +import * as g from '../guards'; +import { expandKeywords, getSentencesFromInput, replaceSynonyms } from '../util/cards'; +import { id, md5 } from '../util/common'; import * as collectionActions from '../actions/collection'; import * as creatorActions from '../actions/creator'; import defaultState from '../store/defaultCreatorState'; -import * as w from '../types'; -import { getSentencesFromInput, replaceSynonyms } from '../util/cards'; -import { id } from '../util/common'; import c from './handlers/cards'; type State = w.CreatorState; export default function creator(oldState: State = defaultState, { type, payload }: w.Action): State { - const state: State = {...oldState}; + const state: State = { ...oldState }; switch (type) { case creatorActions.SET_NAME: @@ -22,7 +23,8 @@ export default function creator(oldState: State = defaultState, { type, payload case creatorActions.SET_TYPE: state.type = payload.type; // Clear parsed state because we're triggering a re-parse. - state.sentences = state.sentences.map((s: w.Sentence) => ({...s, result: {}})); + state.sentences = state.sentences.map((s: w.Sentence) => ({ ...s, result: null })); + state.integrity = []; return state; case creatorActions.SET_ATTRIBUTE: @@ -31,16 +33,22 @@ export default function creator(oldState: State = defaultState, { type, payload case creatorActions.SET_TEXT: { const sentences: string[] = getSentencesFromInput(payload.text); - const validCurrentParses: Record = fromPairs(state.sentences.map((s: w.Sentence) => - [s.sentence, s.result.js] - )) as Record; + const validCurrentParses: Record = fromPairs( + state.sentences + .map((s: w.Sentence) => [s.sentence, s.result] as [string, w.ParseResult]) + .filter(([_, r]) => r && g.isSuccessfulParseResult(r)) as Array<[string, w.SuccessfulParseResult]> + ); state.text = replaceSynonyms(payload.text); state.textSource = payload.textSource; state.sentences = sentences.map((sentence: string) => ({ sentence, - result: validCurrentParses[sentence] ? {js: validCurrentParses[sentence]} : {} + result: validCurrentParses[sentence] || {} })); + + const sentenceHashes: string[] = sentences.map((s) => md5(expandKeywords(s))); + state.integrity = uniqBy(state.integrity.filter(({ input }) => sentenceHashes.includes(input)), 'input'); + return state; } @@ -49,11 +57,18 @@ export default function creator(oldState: State = defaultState, { type, payload return state; case creatorActions.PARSE_COMPLETE: { - state.parserVersion = payload.result.version; + const result: w.ParseResult = payload.result; + + state.parserVersion = result.version; state.sentences = state.sentences.map((s: w.Sentence, idx) => ({ ...s, - result: (idx === payload.idx && s.sentence === payload.sentence) ? payload.result : s.result + result: (idx === payload.idx && s.sentence === payload.sentence) ? result : s.result })); + + if ('hashes' in result) { + state.integrity.push(result.hashes); + } + return state; } @@ -62,10 +77,10 @@ export default function creator(oldState: State = defaultState, { type, payload return state; case creatorActions.TOGGLE_WILL_CREATE_ANOTHER: - return {...state, willCreateAnother: !state.willCreateAnother}; + return { ...state, willCreateAnother: !state.willCreateAnother }; case creatorActions.TOGGLE_PRIVATE: - return {...state, isPrivate: !state.isPrivate}; + return { ...state, isPrivate: !state.isPrivate }; case creatorActions.SAVE_CARD: case creatorActions.ADD_EXISTING_CARD_TO_COLLECTION: @@ -77,7 +92,7 @@ export default function creator(oldState: State = defaultState, { type, payload }; case creatorActions.SAVE_TEMP_VERSION: - return {...state, tempSavedVersion: payload.card }; + return { ...state, tempSavedVersion: payload.card }; case collectionActions.OPEN_CARD_FOR_EDITING: return c.openCardForEditing(state, payload.card); diff --git a/src/common/reducers/handlers/cards.ts b/src/common/reducers/handlers/cards.ts index 780b34d4..74075fbd 100644 --- a/src/common/reducers/handlers/cards.ts +++ b/src/common/reducers/handlers/cards.ts @@ -129,10 +129,10 @@ const cardsHandlers = { openCardForEditing: (state: w.CreatorState, card: w.CardInStore): w.CreatorState => { const newFields: Partial = { ...pick(card, ['id', 'name', 'type', 'text', 'cost', 'spriteID', 'flavorText']), - health: card.stats ? card.stats.health : undefined, - speed: card.stats ? card.stats.speed : undefined, - attack: card.stats ? card.stats.attack : undefined, - sentences: splitSentences(card.text || '').map((s) => ({ sentence: s, result: {} })) + health: card.stats?.health, + speed: card.stats?.speed, + attack: card.stats?.attack, + sentences: splitSentences(card.text || '').map((s) => ({ sentence: s, result: null })) }; return { ...state, ...omitBy(newFields, isUndefined) }; @@ -221,7 +221,7 @@ const cardsHandlers = { return state; }, - saveSet: (state: State, set: w.Set) => { + saveSet: (state: State, set: w.Set): State => { firebase.saveSet(set); if (!state.sets.find((s) => s.id === set.id)) { diff --git a/src/common/reducers/handlers/game/tutorial.ts b/src/common/reducers/handlers/game/tutorial.ts index a3e3b22b..3034273c 100644 --- a/src/common/reducers/handlers/game/tutorial.ts +++ b/src/common/reducers/handlers/game/tutorial.ts @@ -60,7 +60,7 @@ function prevStep(state: State): State { } function deck(cardList: w.CardInGame[]): w.CardInGame[] { - const filler: w.CardInGame[] = new Array(4).fill(cards.oneBotCard).map((card) => instantiateCard({...card, id: id()})); + const filler: w.CardInGame[] = new Array(4).fill(cards.oneBotCard).map((card) => instantiateCard({ ...card, id: id() })); return cardList.concat(filler); } @@ -69,7 +69,7 @@ export function startTutorial(state: State): State { state = { ...state, ...cloneDeep(defaultState), started: true, - usernames: {orange: lookupUsername(), blue: 'Computer'}, + usernames: { orange: lookupUsername(), blue: 'Computer' }, tutorial: true, tutorialActionsPerformed: [], tutorialCurrentStepIdx: 0, @@ -81,10 +81,10 @@ export function startTutorial(state: State): State { cards.oneBotCard, tutorialExclusiveCards.upgradeCard, tutorialExclusiveCards.rechargeCard - ].map((card) => instantiateCard({...card, id: id()}))); + ].map((card) => instantiateCard({ ...card, id: id() }))); state.players.blue.deck = deck([ cards.redBotCard - ].map((card) => instantiateCard({...card, id: id()}))); + ].map((card) => instantiateCard({ ...card, id: id() }))); state.players.orange.objectsOnBoard['3,0,-3'].stats.health = 5; state.players.blue.objectsOnBoard['-3,0,3'].stats.health = 3; state = passTurn(state, 'orange'); @@ -128,6 +128,7 @@ const tutorialExclusiveCards: Record = { name: 'Upgrade', text: 'Give a robot +2 attack and +2 health.', command: '(function () { (function () { save("target", targets["choose"](objectsMatchingConditions("robot", []))); })(); (function () { actions["modifyAttribute"](load("target"), "attack", function (x) { return x + 2; }); })(); (function () { actions["modifyAttribute"](load("target"), "health", function (x) { return x + 2; }); })(); })', + integrity: [], cost: 2, type: TYPE_EVENT }, @@ -137,6 +138,7 @@ const tutorialExclusiveCards: Record = { name: 'Recharge', text: 'All of your robots can move and attack again.', command: '(function () { actions["canMoveAndAttackAgain"](objectsMatchingConditions("robot", [conditions["controlledBy"](targets["self"]())])); })', + integrity: [], cost: 2, type: TYPE_EVENT } diff --git a/src/common/store/cards.ts b/src/common/store/cards.ts index 07007288..41e681f9 100644 --- a/src/common/store/cards.ts +++ b/src/common/store/cards.ts @@ -19,7 +19,8 @@ const coreCard = { abilities: [], metadata: { source: { type: 'builtin' } as w.CardSource - } + }, + integrity: [] }; export const blueCoreCard: w.CardInGame = { diff --git a/src/common/store/coreSet/events.ts b/src/common/store/coreSet/events.ts index e885ddb5..b9bc5218 100644 --- a/src/common/store/coreSet/events.ts +++ b/src/common/store/coreSet/events.ts @@ -11,7 +11,19 @@ export const incinerateCard: w.CardInStore = { "(function () { actions['destroy'](objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())])); })" ], cost: 0, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "ae6ac2aecf9b4840c919c394924d0325", + "output": "7e4fc4e07067bd0ace8530632dc1dcef", + "hmac": "986a8f3ea60a85bbf6e1d5b0c54c5720bce3d4103e8a52f999d03e2c694778cd78908054596ce4d7e18b97dd33b845c600f8f201421a188b7ab3335e5d4ffc45" + }, + { + "input": "41b11ad9531c6943ff3e821e5a1032a4", + "output": "e73dbbc109640bef751c1f45f048df6d", + "hmac": "1b6a80a1c9dffbb5bec77f9d60520dd302e77dc144ee870d5b5fc6da92dc4c742fa9b15880e5696bbd39928198476e7fa6b45c6dc4bcd4f5b71d4947ee9f48a0" + } + ] }; export const superchargeCard: w.CardInStore = { @@ -21,7 +33,14 @@ export const superchargeCard: w.CardInStore = { text: 'Gain 2 energy.', command: "(function () { actions['modifyEnergy'](targets['self'](), function (x) { return x + 2; }); })", cost: 0, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "42024a4bcac077b548b8c75d6451fcfd", + "output": "76c8b0ed05930b2e17b74bb8cf8a0523", + "hmac": "423db2b5553f84105c751952c6f14f25155066503346d2ae04494b64197dd41a2e3d62afcc83744a1f49406eda05a18bd6ab07a36aa1f49a08424a7f1ceda867" + } + ] }; export const concentrationCard: w.CardInStore = { @@ -31,7 +50,14 @@ export const concentrationCard: w.CardInStore = { text: 'Draw 2 cards.', command: "(function () { actions['draw'](targets['self'](), 2); })", cost: 2, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "88fcf9b23b291f41989d58dc70499a29", + "output": "239c03a3a5946ddbc4279b00e283f661", + "hmac": "bc11942bca75220af336c3344f2966a91b96a9b99d7830958f10bd60c426f032d0bc534634eb8c23b66b400759f05a02825ff4502b593c12342976cf68fb77d3" + } + ] }; export const consumeCard: w.CardInStore = { @@ -40,11 +66,23 @@ export const consumeCard: w.CardInStore = { name: 'Consume', text: 'Discard a robot card. Gain life equal to its health.', command: [ - "(function () { actions['discard'](targets['choose'](cardsInHand(targets['self'](), 'robot', []))); })", + "(function () { actions['discard'](targets['choose'](cardsInHand(targets['self'](), 'robot', []), 1)); })", "(function () { actions['modifyAttribute'](objectsMatchingConditions('kernel', [conditions['controlledBy'](targets['self']())]), 'health', function (x) { return x + attributeValue(targets['it'](), 'health'); }); })" ], cost: 2, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "7491afb7baff323907ed66f85c0a46a0", + "output": "6ee5b760506048b24a719b697910a426", + "hmac": "d701935800b6892155f14ee073676c2729a79867e8b354fe59ae0493140cf0d3acdf53cafb76789a57d455b509240da8e4a8b49590c1663530002e778c4d37f7" + }, + { + "input": "92e2226d37676f2436c0b737927d6373", + "output": "dcca5dbea94d90a880b440d8fca8b18d", + "hmac": "ebc8ebf6b2110f2d0dcbcdfd3fa6a26a8fa58f679c33d396695302bc649a890746e5843f2a89cef8c6b63a079e5bde452507270008e0be1d851b9d5f04976b57" + } + ] }; export const gustOfWindCard: w.CardInStore = { @@ -52,9 +90,16 @@ export const gustOfWindCard: w.CardInStore = { id: 'Gust of Wind', name: 'Gust of Wind', text: 'Move a robot up to 2 spaces.', - command: "(function () { (function () { save('target', targets['choose'](objectsMatchingConditions('robot', []))); })(); (function () { actions['moveObject'](load('target'), function () { return targets['choose'](tilesMatchingConditions([conditions['withinDistanceOf'](2, load('target')), conditions['unoccupied']()])); }); })(); })", + command: "(function () { (function () { save('target', targets['choose'](objectsMatchingConditions('robot', []), 1)); })(); (function () { actions['moveObject'](load('target'), function () { return targets['choose'](tilesMatchingConditions([conditions['withinDistanceOf'](2, load('target')), conditions['unoccupied']()]), 1); }); })(); })", cost: 2, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "438b996c342396cb9aa4e23230e081c9", + "output": "e755eef82f5a8e3b1566bd0053355f4e", + "hmac": "37bceb4a1daf00d09374fe94980d2aef23cbb18bb57f433fe155777240bd0f60923cd3df891d8a2b6cb7e89ca68bce31371d621b82d068018ba3d2da430eb4bb" + }, + ] }; export const smashCard: w.CardInStore = { @@ -65,7 +110,14 @@ export const smashCard: w.CardInStore = { text: "Destroy a structure.", cost: 2, command: [ - "(function () { actions['destroy'](targets['choose'](objectsMatchingConditions('structure', []))); })" + "(function () { actions['destroy'](targets['choose'](objectsMatchingConditions('structure', []), 1)); })" + ], + integrity: [ + { + "input": "a8ad4feba2e1aff6cde164ddf183a91a", + "output": "3f32f3ce114dba275544b9127dd19016", + "hmac": "8ea17c2e66559da9ed8831db7685d75f7a1d674d3abcb0bc41b582808277937dec9bca97ce72ae2ef161db573b62a8c543780951198d7ffe03c9ae509bc0df4d" + } ] }; @@ -74,9 +126,16 @@ export const vampirePotionCard: w.CardInStore = { id: 'Vampire Potion', name: 'Vampire Potion', text: 'Give a robot "When this robot attacks, restore 3 health to your kernel"', - command: "(function () { actions['giveAbility'](targets['choose'](objectsMatchingConditions('robot', [])), \"(function () { setTrigger(triggers['afterAttack'](function () { return targets['thisRobot'](); }, 'allobjects'), (function () { actions['restoreHealth'](objectsMatchingConditions('kernel', [conditions['controlledBy'](targets['self']())]), 3); })); })\"); })", + command: "(function () { actions['giveAbility'](targets['choose'](objectsMatchingConditions('robot', []), 1), \"(function () { setTrigger(triggers['afterAttack'](function () { return targets['thisRobot'](); }, 'allobjects'), (function () { actions['restoreHealth'](objectsMatchingConditions('kernel', [conditions['controlledBy'](targets['self']())]), 3); })); })\"); })", cost: 2, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "4339b16d5c21b23048c8af093bac87d9", + "output": "ff9284ae0b8f6e331cd32e3a8188c35c", + "hmac": "10024290d095d6cf3fd7bce8c7b909881b4d1cdfc45933ec403c8a4fcb7a28757db9d909c6d77ca644f31c23344a92c26daf9b8c496f165c8b1377031c34c711" + } + ] }; export const designatedSurvivorCard: w.CardInStore = { @@ -84,9 +143,16 @@ export const designatedSurvivorCard: w.CardInStore = { id: 'Designated Survivor', name: 'Designated Survivor', text: 'Double a robot\'s health', - command: "(function () { actions['modifyAttribute'](targets['choose'](objectsMatchingConditions('robot', [])), 'health', function (x) { return x * 2; }); })", + command: "(function () { actions['modifyAttribute'](targets['choose'](objectsMatchingConditions('robot', []), 1), 'health', function (x) { return x * 2; }); })", cost: 3, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "dad65932849dfcd788d4f8ed0f5d944f", + "output": "61210b87dac522f40e68ee41831267bc", + "hmac": "f9a2bc32403ff0556f6c122f52111f737f568c253f6f175e865cf17cd7b8fc78aeba032619351bd6e05d4a7249919b20c6a808b069898d259d6e8238ce79f10a" + } + ] }; export const discountCard: w.CardInStore = { @@ -96,7 +162,14 @@ export const discountCard: w.CardInStore = { text: 'Reduce the cost of all cards in your hand by 1.', command: "(function () { actions['modifyAttribute'](targets['all'](cardsInHand(targets['self'](), 'anycard', [])), 'cost', function (x) { return x - 1; }); })", cost: 3, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "e3a7a125fb32bd3f54e3495737c3e2eb", + "output": "9f0bc702964c1f21b00f193f5f90e0fc", + "hmac": "83a3223bc3cf6e5d851a329b40f97ab7fa622a5664a62f5e0fa2a905d10daec9e4be07fdcb33228263acac49c001c2aedd16496dd0d584832423aca6df8fd9a1" + } + ] }; export const equalizeCard: w.CardInStore = { @@ -106,7 +179,14 @@ export const equalizeCard: w.CardInStore = { text: 'Set the attack of all robots equal to their health', command: "(function () { actions['setAttribute'](objectsMatchingConditions('robot', []), 'attack', \"(function () { return attributeValue(targets['they'](), 'health'); })\"); })", cost: 3, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "14613194d2553a756729b2fdaacb3b53", + "output": "b91b1391d958d2dbdca0ff03f923f459", + "hmac": "02ff87222ddd84306dd74951caa41c04fd046b2ab8e7526a9e9e84b1a395a8d04afbbc0131daee7b9a091baa567aacdd8d3d88855ce048e1c8a47cc2c3142d84" + } + ] }; export const firestormCard: w.CardInStore = { @@ -114,9 +194,16 @@ export const firestormCard: w.CardInStore = { id: 'Firestorm', name: 'Firestorm', text: 'Deal 1 damage to everything adjacent to a tile.', - command: "(function () { actions['dealDamage'](objectsMatchingConditions('allobjects', [conditions['adjacentTo'](targets['choose'](allTiles()))]), 1); })", + command: "(function () { actions['dealDamage'](objectsMatchingConditions('allobjects', [conditions['adjacentTo'](targets['choose'](allTiles(), 1))]), 1); })", cost: 3, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "be61239012c5b03f80aed3697037a670", + "output": "db0309f342be29e9cd36032814e3382b", + "hmac": "24f6d6307d35c310dfdf67e7412a96febdc0115d8a76551a095476c2bed357a80f8f84b262ae7c458ad6db8efd48dcd150067c51ce9fd3ace29f44b0a25d6f5a" + } + ] }; export const rampageCard: w.CardInStore = { @@ -126,7 +213,14 @@ export const rampageCard: w.CardInStore = { text: 'Give all robots you control +2 attack.', command: "(function () { actions['modifyAttribute'](objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())]), 'attack', function (x) { return x + 2; }); })", cost: 3, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "75c2daaf1ee4c88518ed466dd302350a", + "output": "ab2dbf78de29cb13f3b12ef93e286d58", + "hmac": "0ae0907d2316d0d537400d20c165633af9491967d5a180c8a5db02274333126b85d2eb645319a1864ca6bedb5bf719f73c83c54f57c812df4d4cea58c2f8dca8" + } + ] }; export const shockCard: w.CardInStore = { @@ -134,9 +228,16 @@ export const shockCard: w.CardInStore = { id: 'Shock', name: 'Shock', text: 'Deal 3 damage to a robot.', - command: "(function () { actions['dealDamage'](targets['choose'](objectsMatchingConditions('robot', [])), 3); })", + command: "(function () { actions['dealDamage'](targets['choose'](objectsMatchingConditions('robot', []), 1), 3); })", cost: 3, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "7b48b95142acc2367bde19d912878d91", + "output": "0c285e14053756672a2babff195fb56b", + "hmac": "7a52f2e8f3471735708655e71e7592a99c1752055995e1f2a27f15203ae55abf6b08e2b07dab7ef251742e93eb49c42e04b34f430e5b651d17ec4a959f375aea" + } + ] }; export const missileStrikeCard: w.CardInStore = { @@ -146,7 +247,14 @@ export const missileStrikeCard: w.CardInStore = { text: 'Deal 4 damage to your opponent.', command: "(function () { actions['dealDamage'](targets['opponent'](), 4); })", cost: 4, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "bbdeb9ff72e5f9a82cbe730884f9c66d", + "output": "6efe3eb7c40e39c49752204e49430ed0", + "hmac": "75b7e5de387b1c7f2cadb7e816b428049f1ffe779acd5bcd2558cbc0a5276e2c542bd72c3a63efc2f9cd0bebdc85f9b434d510e634b40208e7eac102d0ae0018" + } + ] }; export const threedomCard: w.CardInStore = { @@ -156,7 +264,14 @@ export const threedomCard: w.CardInStore = { text: 'Set all stats of all robots in play to 3.', command: "(function () { actions['setAttribute'](objectsMatchingConditions('robot', []), 'allattributes', \"(function () { return 3; })\"); })", cost: 4, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "e4f585ac2543068b5b58f9405f49a7b4", + "output": "71c298e922a7189d6cf4f32aa13221e8", + "hmac": "a3e166324b82da555dc3a03b7676db6d21e199d07657f2cdc5d01a05fd0a1c24c4c90892a5e8d4b40eaf9c1259a6162cb49633bf92fd53701c319dd08da8bbcf" + } + ] }; export const wisdomCard: w.CardInStore = { @@ -166,7 +281,14 @@ export const wisdomCard: w.CardInStore = { text: 'Draw cards equal to the number of robots you control.', command: "(function () { actions['draw'](targets['self'](), count(objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())]))); })", cost: 4, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "70a766403f08a20ed94eb36518a096c8", + "output": "ccc9851730dd973ff3c04773129b9e26", + "hmac": "65de19008a0686714335f70b971e9fc93a5189047926a96bdea52ebf02de594c98d22aa116f0b3172cd1e401e42c4d71a368f1b2929de614f8af07d2c7ea8017" + } + ] }; export const earthquakeCard: w.CardInStore = { @@ -176,7 +298,14 @@ export const earthquakeCard: w.CardInStore = { text: 'Destroy all robots that have less than 2 speed.', command: "(function () { actions['destroy'](objectsMatchingConditions('robot', [conditions['attributeComparison']('speed', (function (x) { return x < 2; }))])); })", cost: 5, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "898b59f78eaa2379ca980981fcd8d6f3", + "output": "545f8b999b284d18987b59c011f8866f", + "hmac": "b397802660cfc76a36208d32189deb8e45e984d8295eff5baefec9b7b5f89618dc015d9bd5742e09a2f331dd50a203734c1f924484443b238a8cbb8f9cc87aed" + } + ] }; export const greatSimplificationCard: w.CardInStore = { @@ -186,7 +315,14 @@ export const greatSimplificationCard: w.CardInStore = { text: 'Remove all abilities from all robots', command: "(function () { actions['removeAllAbilities'](objectsMatchingConditions('robot', [])); })", cost: 5, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "ef1654e8d0b207db48366ced086f5a3b", + "output": "6a9ea7dcbe921f072577c707a92d03e0", + "hmac": "360e660416eaae968317dbeb91ebe11277dd5408bbe807e91c183a99ef4990d073c178b601650be87881adf0e198a0913edee8e6a026038efd48f2d2ca8a7dd5" + } + ] }; export const empCard: w.CardInStore = { @@ -199,7 +335,19 @@ export const empCard: w.CardInStore = { "(function () { actions['giveAbility'](objectsMatchingConditions('robot', []), \"(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \\\"(function () { actions['destroy'](targets['thisRobot']()); })\\\")); })\"); })" ], cost: 7, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "444d83b1398cb7682a4c17f7bb8e5e4a", + "output": "796648c3ac66aeb99c33c6a6a7db95bc", + "hmac": "db0cc9db6491508968c735d56de77545a4fd2715e69a7d987f717c1c19d3cd0ca2ab3fc49cc51eb861667b3dda6de5710eba2e96cea36d1be63471957236e272" + }, + { + "input": "1f724062eb7e1f7c39753e20e6db9c8f", + "output": "ee71c8c95effc14ba87ad55d07df5105", + "hmac": "1695985476c10e50e91bcb449251a8edd7e66d56764db8e94af633071367c6d7f81f3ed4618cd13cfb2a5a18c8455e16a2fe7b248297f5fed97870b4ba3e5713" + } + ] }; export const explosiveBoostCard: w.CardInStore = { @@ -212,5 +360,17 @@ export const explosiveBoostCard: w.CardInStore = { "(function () { actions['dealDamage'](objectsMatchingConditions('allobjects', [conditions['controlledBy'](targets['opponent']())]), 1); })" ], cost: 8, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [ + { + "input": "055ca37ecc6c8cad5e5f23c083996728", + "output": "968c2e2492a5414c9944ac95c4635669", + "hmac": "f65da981b3854ce6c1f950f942979282fdfbd69e59d9f84989f992d0fe9fa960e31be50fad7345df207dfdadcd4d42fa26fd21cefd97e8d908216fe5e9b00e43" + }, + { + "input": "a22260f5e695d34b092239e10faaa16d", + "output": "4220ca0e94f843471b1ea9601f68befd", + "hmac": "f89087e80d388ee73a6a462fb6dba77a3e3329211151c250ba19903df17c7c4f19dd381d1ca340e9db75d854119749da4a4e72c8f66fb985245ae47663eb13f8" + } + ] }; diff --git a/src/common/store/coreSet/robots.ts b/src/common/store/coreSet/robots.ts index d9f237c9..2c38518d 100644 --- a/src/common/store/coreSet/robots.ts +++ b/src/common/store/coreSet/robots.ts @@ -15,6 +15,13 @@ export const crawlingWallCard: w.CardInStore = { text: 'Taunt', abilities: [ "(function () { setAbility(abilities['applyEffect'](function () { return objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']()), conditions['controlledBy'](targets['opponent']())]); }, 'canonlyattack', {target: targets['thisRobot']()})); })" + ], + integrity: [ + { + "input": "0c616229c1163efff573a2857f2f7eee", + "output": "98fa8d56eb24bb205332843d7880f941", + "hmac": "9d02993be89025f178af0b6b9b1afe86c6c93629dc0298ffb2872b04d73f8b24a83115a52b2deeed847dd6aefc94f39515762ca5b9db34d52935a35694bff2e2" + } ] }; @@ -32,6 +39,13 @@ export const dojoDiscipleCard: w.CardInStore = { text: 'At the beginning of each of your turns, this robot gains 1 attack.', abilities: [ "(function () { setTrigger(triggers['beginningOfTurn'](function () { return targets['self'](); }), (function () { actions['modifyAttribute'](targets['thisRobot'](), 'attack', function (x) { return x + 1; }); })); })" + ], + integrity: [ + { + "input": "c24ca7efd02c7d173a8bac89bcc0d327", + "output": "731b55e90c7f0a2db860256935727495", + "hmac": "6613bd84b2e920ccaa08c655bbf6f5d355042a3d526126592bb310d78514b5f7b9b61e98507bce16b1bdc3800d2729547a27d3f46a52705953dcd14d6c91a778" + } ] }; @@ -46,7 +60,8 @@ export const oneBotCard: w.CardInStore = { health: 2, speed: 2 }, - abilities: [] + abilities: [], + integrity: [] }; export const madGamblerCard: w.CardInStore = { @@ -64,6 +79,18 @@ export const madGamblerCard: w.CardInStore = { abilities: [ "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { (function () { actions['modifyEnergy'](targets['self'](), function (x) { return x + 2; }); })(); (function () { actions['draw'](targets['self'](), 1); })(); })); })", "(function () { setTrigger(triggers['afterDestroyed'](function () { return targets['thisRobot'](); }, 'anyevent'), (function () { (function () { actions['modifyEnergy'](targets['opponent'](), function (x) { return x + 2; }); })(); (function () { actions['draw'](targets['opponent'](), 1); })(); })); })" + ], + integrity: [ + { + "input": "f5fe64a21d25fdb9d05f6470821367ee", + "output": "6b7a4a623c2ea694c614400bf3b91c6a", + "hmac": "c1ac0775d0504da7bcf70c1eeae85112f4eee478fffd3c191f2c02d0c540d0e20a51e902a8624a49dcae3ccb717ca9f391abb8dbc1e3ff24c7091b7a39b58ce0" + }, + { + "input": "6618492f8670341e9150bc5677c5f8b2", + "output": "b94969f30908615d5651f02ada01b763", + "hmac": "9f5350eee0029fb38f21cadcde5e11ae42722ad9f98e115e0e951eeb38e326ce6ddf4e10ed28fc7796d1fbe1f205dd3f013cbfc077b821376ed785a396e63768" + } ] }; @@ -83,6 +110,18 @@ export const speedyBotCard: w.CardInStore = { abilities: [ "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['it'](); }), (function () { actions['canMoveAndAttackAgain'](targets['thisRobot']()); })); })", "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { actions['dealDamage'](targets['self'](), 2); })); })" + ], + integrity: [ + { + "input": "9f3f650bb9bdad26189038a69187e01f", + "output": "d0c635e7e896d4c840b7760b414b64a8", + "hmac": "e09eeee5d5ec50d3e2149cdcd0bf075e4539d05f5dfe07a4acdd53c5a91067297eb22e28af3d2d49dc6360809ae0ec069e33a86d7f2ecf4b85afe2e45cb298db" + }, + { + "input": "023997b27a7671dd4dbbd6be0d30e20f", + "output": "693d93d6ea93f047a5f60538e0e328af", + "hmac": "667e24152cba5464acbba7fac882feab9b400417d69dcd3c8e71dd336fdada73c48edb1defdd32272cca564522e9d80724994d5b70257fb92e9ec217b5c4d2e0" + } ] }; @@ -93,14 +132,21 @@ export const bloodSwordmasterCard: w.CardInStore = { type: TYPE_ROBOT, cost: 2, spriteID: "hnawh0i9rzb", - text: "Activate: Give a friendly robot +2 attack, then deal 3 damage to your kernel. ", + text: "Activate: Give a friendly robot +2 attack, then deal 3 damage to your kernel.", stats: { health: 1, speed: 3, attack: 3 }, abilities: [ - "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { (function () { actions['modifyAttribute'](targets['choose'](objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())])), 'attack', function (x) { return x + 2; }); })(); (function () { actions['dealDamage'](objectsMatchingConditions('kernel', [conditions['controlledBy'](targets['self']())]), 3); })(); })\")); })" + "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { (function () { actions['modifyAttribute'](targets['choose'](objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())]), 1), 'attack', function (x) { return x + 2; }); })(); (function () { actions['dealDamage'](objectsMatchingConditions('kernel', [conditions['controlledBy'](targets['self']())]), 3); })(); })\")); })" + ], + integrity: [ + { + "input": "d4a531f15b094354cbc4489f1f7b7882", + "output": "09d85c1e67b66b635a73670e03dc2743", + "hmac": "93ebb41274e288e64a8dceab39c218d814ac8e8cfd8936179a3db1e63106d8a01813abdedf31aab228c2b761a2062333ce460ba2487384c03110d2c922b0efb6" + } ] }; @@ -118,6 +164,13 @@ export const medicBotCard: w.CardInStore = { text: 'Activate: Restore 1 health to all adjacent friendly robots', abilities: [ "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { actions['restoreHealth'](objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']()), conditions['controlledBy'](targets['self']())]), 1); })\")); })" + ], + integrity: [ + { + "input": "0a1dff1f81bfbbaa53656957c5e7e3d0", + "output": "2c6352740191ed11d60311ad2c36e4c6", + "hmac": "3e7cca2f8bf590686774e3fcc52bd0ccbe271ab25da4447b6b41dd22f4067c8b20c9199e26632efaa528a427f512430938ccc1283120d2fc8ce63cce18fb2cbd" + } ] }; @@ -136,6 +189,13 @@ export const mercenaryBlacksmithCard: w.CardInStore = { }, abilities: [ "(function () { setTrigger(triggers['beginningOfTurn'](function () { return targets['self'](); }), (function () { (function () { actions['modifyAttribute'](targets['random'](1, objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())])), 'attack', function (x) { return x + 1; }); })(); (function () { actions['modifyAttribute'](targets['random'](1, objectsMatchingConditions('robot', [conditions['controlledBy'](targets['opponent']())])), 'attack', function (x) { return x + 1; }); })(); })); })" + ], + integrity: [ + { + "input": "3168d0c876cb54940738fdb5545277ca", + "output": "41b32a406c8b24042ac16a4ec4d2438f", + "hmac": "b2deefbf9110bd8bf460d6e5af03e8cd0bce610a4cf97ef8f570c3b00fd35a3b59f514b7e93509fbc211d3f72d92293871298ef29221b76fc4b1b64566e2ee9f" + } ] }; @@ -153,7 +213,8 @@ export const thornyBushCard: w.CardInStore = { text: 'Taunt', abilities: [ "(function () { setAbility(abilities['applyEffect'](function () { return objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']()), conditions['controlledBy'](targets['opponent']())]); }, 'canonlyattack', {target: targets['thisRobot']()})); })" - ] + ], + integrity: crawlingWallCard.integrity }; export const twoBotCard: w.CardInStore = { @@ -167,7 +228,8 @@ export const twoBotCard: w.CardInStore = { health: 4, speed: 1 }, - abilities: [] + abilities: [], + integrity: [] }; export const batteryBotCard: w.CardInStore = { @@ -184,6 +246,13 @@ export const batteryBotCard: w.CardInStore = { text: 'At the start of your turn, gain 1 energy and lose 1 life.', abilities: [ "(function () { setTrigger(triggers['beginningOfTurn'](function () { return targets['self'](); }), (function () { (function () { actions['modifyEnergy'](targets['self'](), function (x) { return x + 1; }); })(); (function () { actions['dealDamage'](targets['self'](), 1); })(); })); })" + ], + integrity: [ + { + "input": "dfd1dd4e897a5ec4aa1734eaf20c15fd", + "output": "b4b96e414b8476730bb56ce46aca2439", + "hmac": "16d2cb2ce704f9a3f0f082dbb11e4bded8ac1923d9ddd2154cd8ccc4ecba391b8e27b249d0e15bd748f375a34e746687871632b2ee2461fce1c56cd005e1200d" + } ] }; @@ -201,6 +270,13 @@ export const governmentResearcherCard: w.CardInStore = { text: 'Activate: Pay 1 energy and each player draws a card', abilities: [ "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { (function () { actions['payEnergy'](targets['self'](), 1); })(); (function () { actions['draw'](targets['allPlayers'](), 1); })(); })\")); })" + ], + integrity: [ + { + "input": "6ae162b7224d8377b99170e4852a5a7c", + "output": "79109becd17f46b2ea33e02397dd8e32", + "hmac": "85127bcf02ecd8ee891c6c4b21dc75fbe2693a5070f4cafb8f31e65997b7416a2d77a0e539439c4cd99a13d9f89ebf5250637eaf98c4eb96a13f4f4279e75b1c" + } ] }; @@ -219,6 +295,13 @@ export const hermesCard: w.CardInStore = { }, abilities: [ "(function () { setAbility(abilities['attributeAdjustment'](function () { return objectsMatchingConditions('robot', []); }, 'speed', function (x) { return x + 1; })); })" + ], + integrity: [ + { + "input": "fdbee07b31e37af5e9a564bc4efef981", + "output": "ecbb763dc5d57f7da655edeb894c3eba", + "hmac": "1d03926adb78b58890b77c8fdd92f2920c60c47be3476fc1a62601c4f057d815f689555c06671ad1dcc70921781213034992c94ee72c7fb6e873ba15a9595d87" + } ] }; @@ -237,6 +320,13 @@ export const kernelEaterCard: w.CardInStore = { }, abilities: [ "(function () { setTrigger(triggers['endOfTurn'](function () { return targets['self'](); }), (function () { actions['dealDamage'](targets['allPlayers'](), 1); })); })" + ], + integrity: [ + { + "input": "b87b300a05fe3c90c3255e4fd8a10d65", + "output": "f77826562f992889cc7b5876b4668f0e", + "hmac": "2ce7b927973b882c68711e40131db9af8e8a6eb68053cd07e88f35e562b3c3fa8b2b05571aa18d37dcc7897f5b678c21ec42083c1dc70fd446b0a0d9abc31a06" + } ] }; @@ -254,6 +344,13 @@ export const martyrBotCard: w.CardInStore = { text: 'When this robot is destroyed, take control of all adjacent robots.', abilities: [ "(function () { setTrigger(triggers['afterDestroyed'](function () { return targets['thisRobot'](); }, 'anyevent'), (function () { actions['takeControl'](targets['self'](), objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']())])); })); })" + ], + integrity: [ + { + "input": "da993aaf7aba87f1d80abf2ea13f707c", + "output": "a0c15080c81c7feaf80dfbef11c0af0e", + "hmac": "a5222e240400a2952fd00582e7d3728e73134715389bf0587752e5ccfc295bcdda389bbff465d24c4ac739590c50ae0b42b7c00d3e61365a2be7885bd94497b9" + } ] }; @@ -271,6 +368,13 @@ export const pacifistCard: w.CardInStore = { text: 'At the end of each turn, each kernel gains 1 health', abilities: [ "(function () { setTrigger(triggers['endOfTurn'](function () { return targets['allPlayers'](); }), (function () { actions['modifyAttribute'](objectsMatchingConditions('kernel', []), 'health', function (x) { return x + 1; }); })); })" + ], + integrity: [ + { + "input": "c371b095422b183a4627899dfaec4859", + "output": "71a5a44d34ba5f0acaf57793e8368f9b", + "hmac": "26f206059d78ea65e2ae03ffb681f17dbf36d2e696a1c011f35b4c2dc3f52294894e0b6fd39509f6cf0c7572058d10b4e9bee95acc28e2a30d2711768684b6e6" + } ] }; @@ -289,6 +393,13 @@ export const recklessBerserkerCard: w.CardInStore = { }, abilities: [ "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['it'](); }), (function () { actions['canMoveAndAttackAgain'](targets['thisRobot']()); })); })" + ], + integrity: [ + { + "input": "9f3f650bb9bdad26189038a69187e01f", + "output": "d0c635e7e896d4c840b7760b414b64a8", + "hmac": "e09eeee5d5ec50d3e2149cdcd0bf075e4539d05f5dfe07a4acdd53c5a91067297eb22e28af3d2d49dc6360809ae0ec069e33a86d7f2ecf4b85afe2e45cb298db" + } ] }; @@ -306,6 +417,13 @@ export const recruiterBotCard: w.CardInStore = { text: 'Robots you play cost 1 less energy.', abilities: [ "(function () { setAbility(abilities['attributeAdjustment'](function () { return targets['all'](cardsInHand(targets['self'](), 'robot', [])); }, 'cost', function (x) { return x - 1; })); })" + ], + integrity: [ + { + "input": "4b4d9dd7545e3564b3e404bd992256d4", + "output": "14754e9b27f0a7ec97e28d96b396b6a8", + "hmac": "46bb3f6886d9389d68195c1d1b1eeb8736353cf9565186c062f7de50b44cc4ece0875b28474d3a5ed4d78b081d8ec23d0a76ef4f1404ab6ead205c23b124e42e" + } ] }; @@ -318,13 +436,20 @@ export const recyclerCard: w.CardInStore = { text: "Activate: Discard a card, then draw a card.", cost: 3, abilities: [ - "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { (function () { actions['discard'](targets['choose'](cardsInHand(targets['self'](), 'anycard', []))); })(); (function () { actions['draw'](targets['self'](), 1); })(); })\")); })" + "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { (function () { actions['discard'](targets['choose'](cardsInHand(targets['self'](), 'anycard', []), 1)); })(); (function () { actions['draw'](targets['self'](), 1); })(); })\")); })" ], stats: { attack: 1, health: 2, speed: 2 - } + }, + integrity: [ + { + "input": "c94ad88176f4c0b85e0a03e03d3d731d", + "output": "c399b468377be676877afa0180466d78", + "hmac": "df7c7e790f704485d556e980cb56b779ef66b481d5a352e91bd0218d1b1fee4366a333083c054f786237d00153a04f87d3b1482a7b656ab2a206a3254ce0d0e7" + } + ] }; export const redBotCard: w.CardInStore = { @@ -338,7 +463,8 @@ export const redBotCard: w.CardInStore = { health: 3, speed: 2 }, - abilities: [] + abilities: [], + integrity: [] }; export const roboSlugCard: w.CardInStore = { @@ -356,6 +482,13 @@ export const roboSlugCard: w.CardInStore = { }, abilities: [ "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { actions['dealDamage'](targets['opponent'](), 2); })); })" + ], + integrity: [ + { + "input": "e3e4c4877adacf6ce7ffaddb2356f391", + "output": "361c2d6f424b12ae0d57ed8030522657", + "hmac": "322c3961706dd0b2247cdd1133a0f1e18bced226e3fecf7da64f401e04d30ed0eb05d07a85d1c64f5996f7bf8c112af6e3b04863df1eeaf89f330616e004bba0" + } ] }; @@ -373,6 +506,13 @@ export const bloodDonorCard: w.CardInStore = { text: "Startup: Give adjacent robots 3 health", abilities: [ "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { actions['modifyAttribute'](objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']())]), 'health', function (x) { return x + 3; }); })); })" + ], + integrity: [ + { + "input": "32c5baedad0a6039082cefdc58c32a44", + "output": "457746d413fa028885734cc573a2bea9", + "hmac": "e7404ef352cb6a2bd84e313f9f7bb7326dc15cec21959684f5797f94e03c2ba36971a45f85212010969c6982ad67baef6f139db911abebea935e6cf08ad509fa" + } ] }; @@ -387,7 +527,8 @@ export const blueBotCard: w.CardInStore = { health: 8, speed: 1 }, - abilities: [] + abilities: [], + integrity: [] }; export const defenderBotCard: w.CardInStore = { @@ -405,6 +546,18 @@ export const defenderBotCard: w.CardInStore = { abilities: [ "(function () { setAbility(abilities['applyEffect'](function () { return targets['thisRobot'](); }, 'cannotattack')); })", "(function () { setAbility(abilities['applyEffect'](function () { return objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']()), conditions['controlledBy'](targets['opponent']())]); }, 'canonlyattack', {target: targets['thisRobot']()})); })" + ], + integrity: [ + { + "input": "30319e11c37cff50e2489e183e3bf629", + "output": "2536d7249c0ce4f617b768f5748ff9b5", + "hmac": "4b192fc8980968023fb7a10edf316a5bda4f36bed85c993b801313a09f4379bc0733748ecba75c5a38a8fbffbbbd7d84408fd8e81a922db56954b1642e43ee35" + }, + { + "input": "0c616229c1163efff573a2857f2f7eee", + "output": "98fa8d56eb24bb205332843d7880f941", + "hmac": "9d02993be89025f178af0b6b9b1afe86c6c93629dc0298ffb2872b04d73f8b24a83115a52b2deeed847dd6aefc94f39515762ca5b9db34d52935a35694bff2e2" + } ] }; @@ -422,7 +575,14 @@ export const energyHoarderCard: w.CardInStore = { attack: 4 }, abilities: [ - "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { (function () { actions['payEnergy'](targets['self'](), 3); })(); (function () { (function () { actions['discard'](targets['choose'](cardsInHand(targets['self'](), 'anycard', []))); })(); (function () { (function () { save('target', targets['thisRobot']()); })(); (function () { actions['modifyAttribute'](load('target'), 'attack', function (x) { return x + 1; }); })(); (function () { actions['modifyAttribute'](load('target'), 'health', function (x) { return x + 1; }); })(); })(); })(); })\")); })" + "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { (function () { actions['payEnergy'](targets['self'](), 3); })(); (function () { (function () { actions['discard'](targets['choose'](cardsInHand(targets['self'](), 'anycard', []), 1)); })(); (function () { (function () { save('target', targets['thisRobot']()); })(); (function () { actions['modifyAttribute'](load('target'), 'attack', function (x) { return x + 1; }); })(); (function () { actions['modifyAttribute'](load('target'), 'health', function (x) { return x + 1; }); })(); })(); })(); })\")); })" + ], + integrity: [ + { + "input": "48790908919a4c555db9f1969dfd6715", + "output": "b37d27ba6cf392b69a54be2faed48428", + "hmac": "e5f08e9e6b6d87e77be83c029faa7952a5f1487694461e9f4450ea936813879af8d27e023122ba8dfc43a88d2154709bd9aea946c5f64b984351d29af9df5d64" + } ] }; @@ -441,6 +601,18 @@ export const friendlyRiotShieldCard: w.CardInStore = { abilities: [ "(function () { setAbility(abilities['applyEffect'](function () { return targets['thisRobot'](); }, 'cannotattack')); })", "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['it'](); }), (function () { actions['canMoveAndAttackAgain'](targets['thisRobot']()); })); })" + ], + integrity: [ + { + "input": "30319e11c37cff50e2489e183e3bf629", + "output": "2536d7249c0ce4f617b768f5748ff9b5", + "hmac": "4b192fc8980968023fb7a10edf316a5bda4f36bed85c993b801313a09f4379bc0733748ecba75c5a38a8fbffbbbd7d84408fd8e81a922db56954b1642e43ee35" + }, + { + "input": "9f3f650bb9bdad26189038a69187e01f", + "output": "d0c635e7e896d4c840b7760b414b64a8", + "hmac": "e09eeee5d5ec50d3e2149cdcd0bf075e4539d05f5dfe07a4acdd53c5a91067297eb22e28af3d2d49dc6360809ae0ec069e33a86d7f2ecf4b85afe2e45cb298db" + } ] }; @@ -458,6 +630,13 @@ export const knowledgeBotCard: w.CardInStore = { text: 'At the start of your turn, draw a card and lose 2 life.', abilities: [ "(function () { setTrigger(triggers['beginningOfTurn'](function () { return targets['self'](); }), (function () { (function () { actions['draw'](targets['self'](), 1); })(); (function () { actions['dealDamage'](targets['self'](), 2); })(); })); })" + ], + integrity: [ + { + "input": "26cb47822589b8161757ef08f8c6f86f", + "output": "7922f07f92cccccd0800fb9c086d8c62", + "hmac": "6f8f0c866593e219fd84a2179d6d5f11165d43fda1e439ff5ca50ef02f198f2ab771a852e270ee12377ec4fa01bb590631d23725b6e8d6c4b722917e07de86c7" + } ] }; @@ -475,6 +654,13 @@ export const leapFrogBotCard: w.CardInStore = { text: 'Jump', abilities: [ "(function () { setAbility(abilities['applyEffect'](function () { return targets['thisRobot'](); }, 'canmoveoverobjects')); })" + ], + integrity: [ + { + "input": "6203d64b5c27a295cd27c6d58462981c", + "output": "5a8c452c71b1b36cace0a6d65e6a0f5f", + "hmac": "b71807fd5988a65e8a79b495c971808db8012565c68d61f197cab093d579faf164369fda3f6f7a97d3cc1d585650986f52114a57c8e616dbdaabc87179f3e13c" + } ] }; @@ -492,6 +678,13 @@ export const monkeyBotCard: w.CardInStore = { text: 'When this robot attacks, it deals damage to all adjacent robots instead.', abilities: [ "(function () { setTrigger(triggers['afterAttack'](function () { return targets['thisRobot'](); }, 'allobjects'), (function () { actions['dealDamage'](objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']())]), attributeValue(targets['thisRobot'](), 'attack')); }), {override: true}); })" + ], + integrity: [ + { + "input": "441987aa1733c3e4393549c6b394980f", + "output": "d6f71424c88ff21130202adf3f1eca14", + "hmac": "de8d8490a319ab97866092abfc4c3c971378d9eeffaf617c5398414c0efb9b2333e5f88edb30035f56c390ebe29843ff97194db193432ac84a7eb16e9762dda9" + } ] }; @@ -509,6 +702,13 @@ export const calmMonkCard: w.CardInStore = { text: 'At the start of your turn, pay 1 energy and this robot gains 1 health.', abilities: [ "(function () { setTrigger(triggers['beginningOfTurn'](function () { return targets['self'](); }), (function () { (function () { actions['payEnergy'](targets['self'](), 1); })(); (function () { actions['modifyAttribute'](targets['thisRobot'](), 'health', function (x) { return x + 1; }); })(); })); })" + ], + integrity: [ + { + "input": "36bc2cfa076ce8998bf2d5b766460db4", + "output": "3f6daed32a3601fba91bdaa66ba2a3cd", + "hmac": "a53e753cd39f0997e349ab62f04a0ab436178e2bff2a0fa35b9e725d0c84b765f41efb70dd03a419697796238b99a91561eac2b6914ce01f5f7a7bc89bd9cee5" + } ] }; @@ -527,6 +727,18 @@ export const royalGuardCard: w.CardInStore = { abilities: [ "(function () { setAbility(abilities['applyEffect'](function () { return targets['thisRobot'](); }, 'cannotattack')); })", "(function () { setAbility(abilities['applyEffect'](function () { return objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']()), conditions['controlledBy'](targets['opponent']())]); }, 'canonlyattack', {target: targets['thisRobot']()})); })" + ], + integrity: [ + { + "input": "30319e11c37cff50e2489e183e3bf629", + "output": "2536d7249c0ce4f617b768f5748ff9b5", + "hmac": "4b192fc8980968023fb7a10edf316a5bda4f36bed85c993b801313a09f4379bc0733748ecba75c5a38a8fbffbbbd7d84408fd8e81a922db56954b1642e43ee35" + }, + { + "input": "0c616229c1163efff573a2857f2f7eee", + "output": "98fa8d56eb24bb205332843d7880f941", + "hmac": "9d02993be89025f178af0b6b9b1afe86c6c93629dc0298ffb2872b04d73f8b24a83115a52b2deeed847dd6aefc94f39515762ca5b9db34d52935a35694bff2e2" + } ] }; @@ -544,6 +756,13 @@ export const botOfPainCard: w.CardInStore = { text: 'At the end of each turn, each robot takes 1 damage.', abilities: [ "(function () { setTrigger(triggers['endOfTurn'](function () { return targets['allPlayers'](); }), (function () { actions['dealDamage'](objectsMatchingConditions('robot', []), 1); })); })" + ], + integrity: [ + { + "input": "c1626f77c6c6358a9d922dffacea2fd1", + "output": "870dcae63b27ff099662c8a8060e9445", + "hmac": "5090413927a27ff33a7b2a29f82d9490bae46e5145ce784b22107691f3272d32ae818918d4139a7a91cef71af981a6bcaa5dae8151508550e5e9725f14f1ac8e" + } ] }; @@ -560,7 +779,14 @@ export const flametongueBotCard: w.CardInStore = { }, text: 'When this robot is played, deal 4 damage.', abilities: [ - "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { actions['dealDamage'](targets['choose'](objectsMatchingConditions('allobjects', [])), 4); })); })" + "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { actions['dealDamage'](targets['choose'](objectsMatchingConditions('allobjects', []), 1), 4); })); })" + ], + integrity: [ + { + "input": "bfa1cf6bc3e2d0fb62c8a32ae8053351", + "output": "635029b9f442520be1b416341c5857c8", + "hmac": "e85902727c53a605dc33e6c9d083f41433eeb475deda883133bc8132f7acf62b3f19d8732d290d222dd22649d824d62c60fe4788dc067fb411e5476abb5de31b" + } ] }; @@ -576,7 +802,8 @@ export const effectiveTrollCard: w.CardInStore = { speed: 2 }, abilities: [ - ] + ], + integrity: [] }; export const generalBotCard: w.CardInStore = { @@ -594,5 +821,17 @@ export const generalBotCard: w.CardInStore = { abilities: [ "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { actions['canMoveAgain'](other(objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())]))); })); })", "(function () { setAbility(abilities['attributeAdjustment'](function () { return objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']())]); }, 'attack', function (x) { return x + 1; })); })" + ], + integrity: [ + { + "input": "60f5eecc5fde0446c550c6c0225eab1a", + "output": "e2f597307a723bab97343438fc59524c", + "hmac": "38261cc5e448d8973579897aa438ca4d20254becbbf61ae40a0c50c2e84ed9700d544901dbcbf104310a99140e097fbd0bafcf1f76ccae11fc49cf82af7f62d0" + }, + { + "input": "f61614596adabd380f5dfe880e554570", + "output": "cf179b4ad59269e666cc3075245ca5d3", + "hmac": "d24e250324d0d936cecedd5cfac7010a85704626b55d8a8d8fe3fc5503830013c8629eeccd30c8b770110e973067a18f1d039b326d43092f80fc90aa3ac480f7" + } ] }; diff --git a/src/common/store/coreSet/structures.ts b/src/common/store/coreSet/structures.ts index 9e08932a..9e1987e3 100644 --- a/src/common/store/coreSet/structures.ts +++ b/src/common/store/coreSet/structures.ts @@ -13,6 +13,13 @@ export const fortificationCard: w.CardInStore = { text: 'Your adjacent robots have +1 health.', abilities: [ "(function () { setAbility(abilities['attributeAdjustment'](function () { return objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']()), conditions['controlledBy'](targets['self']())]); }, 'health', function (x) { return x + 1; })); })" + ], + integrity: [ + { + "input": "bd4ba675e86063a7d8b389d8089eeb2a", + "output": "db15e5b2d0137e55e14b8177bf1f2355", + "hmac": "ba18bce9456aabeb39b7cdd3f313f6921cc5e72d0954dedd71855526105ded48f31536140dbae5deafe868ea58a14797db8a68f5b1db5753177f96e0c9f174e5" + } ] }; @@ -28,6 +35,13 @@ export const energyWellCard: w.CardInStore = { text: "At the start of each player's turn, that player gains 1 energy if they control an adjacent robot.", abilities: [ "(function () { setTrigger(triggers['beginningOfTurn'](function () { return targets['allPlayers'](); }), (function () { if (globalConditions['collectionExists'](objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']()), conditions['controlledBy'](targets['itP']())]))) { ((function () { actions['modifyEnergy'](targets['itP'](), function (x) { return x + 1; }); }))(); } })); })" + ], + integrity: [ + { + "input": "b2f985288542d0e4f49fbb3635c50c84", + "output": "75521b3ca657616e4e412e27c00d65ea", + "hmac": "411fa5868630761082391a781e50bc34fa81757b9d8f9d2b3e609b6d559a27f4f1e61ea5b5c1300b43a38a717b1dcee6ba638b30a8d846b7e9e72087f982b27b" + } ] }; @@ -44,7 +58,14 @@ export const antiGravityFieldCard: w.CardInStore = { ], stats: { health: 5 - } + }, + integrity: [ + { + "input": "0895b001b89fc6dd7f42a6824f501c6b", + "output": "13d017ed1ada8cae88bac85c82376314", + "hmac": "a391fb7e2a5997c24c927ad54f317d1d6517fbcb8b991b83f334e5582a3f595b594e9e76d4965978d0a325a9e1fc54a6041344d74de1c3547c397a088397d875" + } + ] }; export const acceleratorCard: w.CardInStore = { @@ -59,6 +80,13 @@ export const acceleratorCard: w.CardInStore = { text: 'Startup: Give all friendly robots +1 speed and -1 health.', abilities: [ "(function () { setTrigger(triggers['afterPlayed'](function () { return targets['thisRobot'](); }), (function () { (function () { save('target', objectsMatchingConditions('robot', [conditions['controlledBy'](targets['self']())])); })(); (function () { actions['modifyAttribute'](load('target'), 'speed', function (x) { return x + 1; }); })(); (function () { actions['modifyAttribute'](load('target'), 'health', function (x) { return x - 1; }); })(); })); })" + ], + integrity: [ + { + "input": "c1d638e68297e2b287d25aab4a64465a", + "output": "53acb3d8d207cf30d315bdc7e58a635a", + "hmac": "987c2b16767f88e55fce784d53025415aecca305f4cb97d8aee1a6e076f9fddc9bfc5b1d9563711ffd4c0569262a07c86de96af6b6539e815e7f0513677be436" + } ] }; @@ -75,7 +103,14 @@ export const magpieMachineCard: w.CardInStore = { ], stats: { health: 4 - } + }, + integrity: [ + { + "input": "eec456c56851ff5f598df86d6ea2421a", + "output": "98928c8a0ec5773982ab2e0f65efb8b6", + "hmac": "fed4c23196820a75752d111822e4955c0ba6aedc4ce04651f29a274760e49e58cc1b87872d75498192ed6b50e4f452396005baeade5eecc72415b6c03bfd54fb" + } + ] }; export const arenaCard: w.CardInStore = { @@ -90,6 +125,13 @@ export const arenaCard: w.CardInStore = { text: 'Whenever a robot is destroyed in combat, deal 1 damage to its controller.', abilities: [ "(function () { setTrigger(triggers['afterDestroyed'](function () { return targets['all'](objectsMatchingConditions('robot', [])); }, 'combat'), (function () { actions['dealDamage'](targets['controllerOf'](targets['it']()), 1); })); })" + ], + integrity: [ + { + "input": "f26b9afa222c0d736946610541a793e3", + "output": "c450d430923291710510a93bb256901a", + "hmac": "5400cb8ddcdb7c5ee2bf88984dc6e3b6bfa900481de5ed46c67dc4bfaab498b80b4d3364a430b907ca5b49f19343ad2f8a6250fced57f2f0a31326af2ff379e6" + } ] }; @@ -106,6 +148,18 @@ export const killingBeamCard: w.CardInStore = { abilities: [ "(function () { setTrigger(triggers['beginningOfTurn'](function () { return targets['self'](); }), (function () { actions['modifyAttribute'](targets['thisRobot'](), 'health', function (x) { return x + 1; }); })); })", "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { actions['destroy'](objectsMatchingConditions('robot', [conditions['attributeComparison']('cost', (function (x) { return x === attributeValue(targets['thisRobot'](), 'health'); }))])); })\")); })" + ], + integrity: [ + { + "input": "abd5036b9241d72801d89e5dc72a6f19", + "output": "8aea19b1b12516c07dd5a747e467fd7f", + "hmac": "cca90dc0eea934d36d91ad56d4b643499e6e845e309d8a884df62f039e665c9f3adc11d77d66b38856172a73c48211bf11c7e955fccfacd9c1dfaf196d688e6c" + }, + { + "input": "3a34c12e9e810cd9176d74d1fa2b224e", + "output": "e9babad5f7dc0a176af07a001ca9c091", + "hmac": "3e83e2666d6ab595dda99a017b4ef0efe602270ed439a3be89d46b072016e07dd7c263f288f9000a586b13e94752861e9161a9c1055768e2fbc2e686459202c1" + } ] }; @@ -122,6 +176,18 @@ export const healingWellCard: w.CardInStore = { abilities: [ "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { actions['destroy'](targets['thisRobot']()); })\")); })", "(function () { setTrigger(triggers['afterDestroyed'](function () { return targets['thisRobot'](); }, 'anyevent'), (function () { actions['restoreHealth'](objectsMatchingConditions('robot', [conditions['adjacentTo'](targets['thisRobot']())])); })); })" + ], + integrity: [ + { + "input": "eb374bc69c86d6089b05ece9c9e5a0fc", + "output": "ff4ece129d068ee933249d0564390d48", + "hmac": "c8b02e5afe7048f977e16e232d058c59c21eb60d62abe159d9a7811b40e254e1f17b608d27b97c08b5d26fe77dccf7e90b0710029a2c9b720229d47873d436d1" + }, + { + "input": "c718fe9c6d59e37597406e0119dc8fea", + "output": "9580ababa82ac6b7bb47b1cbc6396bfd", + "hmac": "a4b82743ff226cf572dfd39c13707bfe9fbb1c130c6708ed82022971ed78e250baf185d91926a1f06fccd4e12ce3f1066611fd15292916910f45adf8b90b91d7" + } ] }; @@ -137,6 +203,13 @@ export const mirrorCard: w.CardInStore = { text: 'When you play a robot, this structure becomes a copy of that robot.', abilities: [ "(function () { setTrigger(triggers['afterCardPlay'](function () { return targets['self'](); }, 'robot'), (function () { actions['become'](targets['thisRobot'](), targets['copyOf'](targets['that']())); })); })" + ], + integrity: [ + { + "input": "e9e0b81b8aa76ce1ab3f42f588cdabb0", + "output": "dc274160fe99b599b421ed509ca16a76", + "hmac": "3284530ef80772c4cb922a33d72a6e76b8f672cef6141f50178898d907fb460c536a00b2d20c7b548d0f581905c2c7ff4fc397b58f24d82898035a47ce692860" + } ] }; @@ -153,5 +226,17 @@ export const theBombCard: w.CardInStore = { abilities: [ "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { actions['destroy'](targets['thisRobot']()); })\")); })", "(function () { setTrigger(triggers['afterDestroyed'](function () { return targets['thisRobot'](); }, 'anyevent'), (function () { actions['dealDamage'](objectsMatchingConditions('allobjects', [conditions['withinDistanceOf'](2, targets['thisRobot']())]), 2); })); })" + ], + integrity: [ + { + "input": "eb374bc69c86d6089b05ece9c9e5a0fc", + "output": "ff4ece129d068ee933249d0564390d48", + "hmac": "c8b02e5afe7048f977e16e232d058c59c21eb60d62abe159d9a7811b40e254e1f17b608d27b97c08b5d26fe77dccf7e90b0710029a2c9b720229d47873d436d1" + }, + { + "input": "1791d96b0bc46f7084c80a89e0e1b9ca", + "output": "cd6f38d0c55dd6f5d6113d15ce71e50a", + "hmac": "63f681c429a431abf94032cb83d152fe07927929f162fdbe31429cefd9f59e3948e3ce355864546f7a550b046837b80d283eb94c40e84d663cd5b88f5fcf2b2e" + } ] }; diff --git a/src/common/store/defaultCreatorState.ts b/src/common/store/defaultCreatorState.ts index e339f577..a14f9c76 100644 --- a/src/common/store/defaultCreatorState.ts +++ b/src/common/store/defaultCreatorState.ts @@ -8,6 +8,7 @@ const defaultCreatorState: w.CreatorState = { type: 0, text: '', sentences: [], + integrity: [], flavorText: '', attack: 1, speed: 1, diff --git a/src/common/types.d.ts b/src/common/types.d.ts index 5b00c91c..58f9f9da 100644 --- a/src/common/types.d.ts +++ b/src/common/types.d.ts @@ -103,6 +103,7 @@ export interface CardInStore { spriteV?: number parserV?: string | null metadata: CardMetadata + integrity: Hashes[] // TODO what to do with the below properties when a card is edited? wipe them? originalParserV?: string // the original parserV under which this card was created, if this card was migrated migrationBackup?: Array<{ parserV: string, abilities: string[] }> // parsed JS for previous parser versions, if this card was migrated @@ -249,6 +250,7 @@ export interface CreatorState { flavorText: string health: number id: string | null + integrity: Hashes[] isPrivate?: boolean name: string parserVersion: string | null @@ -488,16 +490,29 @@ export type DraftState = PerPlayer<{ export interface Sentence { sentence: string - result: ParseResult + result: ParseResult | null } -export interface ParseResult { - error?: string - js?: StringRepresentationOf<() => void> - unrecognizedTokens?: string[] - suggestions?: string[] - parsed?: boolean // used by DictionaryDialog - version?: string +export type ParseResult = SuccessfulParseResult | FailedParseResult + +export interface Hashes { + input: string // md5(input) + output: string // md5(output) + hmac: string // hmac-sha512(md5(input).md5(output)) +} + +export interface SuccessfulParseResult { + input: string + js: StringRepresentationOf<() => void> + hashes: Hashes + version: string +} + +export interface FailedParseResult { + error: string + unrecognizedTokens: string[] + suggestions: string[] + version: string } // Socket state subcomponents diff --git a/src/common/util/cards.ts b/src/common/util/cards.ts index 532585e8..218594a5 100644 --- a/src/common/util/cards.ts +++ b/src/common/util/cards.ts @@ -1,6 +1,6 @@ import { capitalize, compact, debounce, flatMap, fromPairs, has, - isArray, mapValues, omit, pick, reduce, uniqBy + isArray, isEqual, mapValues, omit, pick, reduce, uniqBy } from 'lodash'; import * as w from '../types'; @@ -14,7 +14,7 @@ import { import defaultState from '../store/defaultCollectionState'; import { CreatorStateProps } from '../containers/Creator'; -import { ensureInRange, id as generateId } from './common'; +import { ensureInRange, id as generateId, md5 } from './common'; import { fetchUniversal, onLocalhost } from './browser'; import { indexParsedSentence, lookupCurrentUser } from './firebase'; @@ -85,11 +85,11 @@ function cardSourceForCurrentUser(): w.CardSource { /** Converts card from cardCreator store format -> format for collection and game stores. */ export function createCardFromProps(props: w.CreatorState): w.CardInStore { const { - attack, cost, health, flavorText, id, isPrivate, name, parserVersion, + attack, cost, health, flavorText, id, integrity, isPrivate, name, parserVersion, sentences: rawSentences, speed, spriteID, type } = props; const sentences = rawSentences.filter((s: { sentence: string }) => /\S/.test(s.sentence)); - const command = sentences.map((s: { result: { js?: string } }) => s.result.js!); + const command = sentences.map((s) => (s.result as w.SuccessfulParseResult).js); const card: w.CardInStore = { id: id || generateId(), @@ -99,6 +99,7 @@ export function createCardFromProps(props: w.CreatorState): w.CardInStore { spriteV: SPRITE_VERSION, parserV: parserVersion, text: sentences.map((s: { sentence: string }) => `${s.sentence}. `).join(''), + integrity, flavorText, cost, metadata: { @@ -139,7 +140,9 @@ export function validateCardInCreator(props: CreatorStateProps): CardValidationR const nonEmptySentences: w.Sentence[] = sentences.filter((s) => /\S/.test(s.sentence)); const hasCardText: boolean = nonEmptySentences.length > 0; - const parseErrors: string[] = compact(nonEmptySentences.map((s) => s.result.error)).map((error) => + const parseErrors: string[] = compact( + nonEmptySentences.map((s) => s.result && g.isFailedParseResult(s.result) ? s.result.error : null) + ).map((error) => (`${error}.`) .replace('..', '.') .replace('Parser did not produce a valid expression', 'Parser error') @@ -157,7 +160,7 @@ export function validateCardInCreator(props: CreatorStateProps): CardValidationR return 'Action cards must have card text.'; } else if (parseErrors.length > 0) { return parseErrors.join(' '); - } else if (nonEmptySentences.find((s) => !s.result.js)) { + } else if (nonEmptySentences.find((s) => s.result === null)) { return 'Sentences are still being parsed ...'; } else if (nonEmptySentences.filter((s) => s.sentence.toLowerCase().includes('replace ')).length > 1) { // https://github.com/wordbots/wordbots-core/issues/1811 @@ -281,21 +284,22 @@ export function parseCard( sentences, isEvent ? 'event' : 'object', (idx, _, response) => { - if (response.error) { + if ('error' in response) { const errorMsg = `Received '${response.error}' while parsing '${sentences[idx]}'`; if (errorCallback) { errorCallback(errorMsg); } else { throw new Error(errorMsg); } - } - - parseResults[idx] = response.js!; - - // Are we done parsing? - if (compact(parseResults).length === sentences.length) { - card[isEvent ? 'command' : 'abilities'] = parseResults; - callback(card, parseResults); + } else { + parseResults[idx] = response.js!; + card.integrity = [...(card.integrity || []), response.hashes]; + + // Are we done parsing? + if (compact(parseResults).length === sentences.length) { + card[isEvent ? 'command' : 'abilities'] = parseResults; + callback(card, parseResults); + } } }, !opts?.disableIndexing, @@ -309,7 +313,7 @@ export async function lookupParserVersion(): Promise<{ version: string, sha: str } // -// 3.5. Keyword abilities. +// 3.1. Keyword abilities. // /** Given a sentence, return an array of comma-separated phrases. */ @@ -368,6 +372,75 @@ export function quoteKeywords(sentence: string): string { return contractKeywords(expandKeywords(sentence, true)); } +// +// 3.2. Card integrity. +// + +export function getCardAbilities(card: w.CardInStore): string[] { + return card.type === TYPE_EVENT ? compact(isArray(card.command) ? card.command : [card.command]) : (card.abilities || []); +} + +export function getCardSentences(card: w.CardInStore): string[] { + return compact(getSentencesFromInput(card.text || '').map((s) => expandKeywords(s.replace('\n', '').trim()))); +} + +// eslint-disable-next-line no-console +export function validateIntegrityHashesAreComplete(card: w.CardInStore, logger: (str: string) => void = console.log): boolean { + const integrity: w.Hashes[] = uniqBy(card.integrity || [], 'input'); + + const sentenceHashes: string[] = getCardSentences(card).map(md5); + const abilityHashes: string[] = getCardAbilities(card).map(md5); + + const isValid: boolean = ( + isEqual(new Set(sentenceHashes), new Set(integrity.map((i) => i.input))) + && isEqual(new Set(abilityHashes), new Set(integrity.map((i) => i.output))) + ); + + if (!isValid) { + logger('Found a card with missing or incomplete integrity hashes:'); + logger(JSON.stringify({ + cardId: card.id, + integrity, + sentenceHashes, + abilityHashes, + sentences: getCardSentences(card) + }, null, 2)); + } + + return isValid; +} + +export async function checkValidityOfIntegrityHashes(cards: w.CardInStore[]): Promise<{ + validCards: w.CardInStore[] + invalidCards: w.CardInStore[] + statistics: { numValidHashes: number, numInvalidHashes: number } +}> { + const hashesToVerify: w.Hashes[] = uniqBy(cards.flatMap((c) => c.integrity || []), 'hmac'); + const verifyHashesResponse = await fetchUniversal(`${PARSER_URL}/verify-hashes`, { + method: 'POST', + body: JSON.stringify(hashesToVerify), + headers: { 'Content-Type': 'application/json' } + }); + const verifyHashesResults: Array<{ input: string, valid: boolean }> = await verifyHashesResponse.json(); + const invalidHashInputs: string[] = verifyHashesResults.filter((r) => !r.valid).map((r) => r.input); + const cardsWithInvalidHashes: w.CardInStore[] = cards.filter((c) => (c.integrity || []).map(i => i.input).some((i) => invalidHashInputs.includes(i))); + + return { + validCards: cards.filter((card) => !cardsWithInvalidHashes.map((c) => c.id).includes(card.id)), + invalidCards: cardsWithInvalidHashes, + statistics: { numValidHashes: verifyHashesResults.filter((r) => r.valid).length, numInvalidHashes: verifyHashesResults.filter((r) => !r.valid).length } + }; +} + +export async function verifyIntegrityOfCards(cards: w.CardInStore[]): Promise<{ invalidCards: w.CardInStore[] }> { + const cardsWithCompleteIntegrityHashes = cards.filter((c) => validateIntegrityHashesAreComplete(c)); + const cardsWithCompleteIntegrityHashesIds = cardsWithCompleteIntegrityHashes.map((c) => c.id); + const cardsWithIncompleteIntegrityHashes = cards.filter((c) => !cardsWithCompleteIntegrityHashesIds.includes(c.id)); + + const { invalidCards } = await checkValidityOfIntegrityHashes(cardsWithCompleteIntegrityHashes); + return { invalidCards: [...cardsWithIncompleteIntegrityHashes, ...invalidCards] }; +} + // // 4. Import/export. // diff --git a/src/common/util/common.ts b/src/common/util/common.ts index 4a0d246a..4cfe1d14 100644 --- a/src/common/util/common.ts +++ b/src/common/util/common.ts @@ -147,3 +147,52 @@ export function logToDiscord(msg: string): void { } } } + +// Formatted version of a popular md5 implementation +// Original copyright (c) Paul Johnston & Greg Holt. +// The function itself is now 42 lines long. +// https://stackoverflow.com/a/60467595 +export function md5(inputString: string): string { + const hc = "0123456789abcdef"; + function rh(n: number) { let j, s = ""; for (j = 0; j <= 3; j++) s += hc.charAt((n >> (j * 8 + 4)) & 0x0F) + hc.charAt((n >> (j * 8)) & 0x0F); return s; } + function ad(x: number, y: number) { const l = (x & 0xFFFF) + (y & 0xFFFF); const m = (x >> 16) + (y >> 16) + (l >> 16); return (m << 16) | (l & 0xFFFF); } + function rl(n: number, c: number) { return (n << c) | (n >>> (32 - c)); } + function cm(q: number, a: number, b: number, x: number, s: number, t: number) { return ad(rl(ad(ad(a, q), ad(x, t)), s), b); } + function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return cm((b & c) | ((~b) & d), a, b, x, s, t); } + function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return cm((b & d) | (c & (~d)), a, b, x, s, t); } + function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return cm(b ^ c ^ d, a, b, x, s, t); } + function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number) { return cm(c ^ (b | (~d)), a, b, x, s, t); } + function sb(x: string) { + let i; const nblk = ((x.length + 8) >> 6) + 1; const blks = new Array(nblk * 16); for (i = 0; i < nblk * 16; i++) blks[i] = 0; + for (i = 0; i < x.length; i++) blks[i >> 2] |= x.charCodeAt(i) << ((i % 4) * 8); + blks[i >> 2] |= 0x80 << ((i % 4) * 8); blks[nblk * 16 - 2] = x.length * 8; return blks; + } + const x = sb(`${inputString}`); + let i, a = 1732584193, b = -271733879, c = -1732584194, d = 271733878, olda, oldb, oldc, oldd; + for (i = 0; i < x.length; i += 16) { + olda = a; oldb = b; oldc = c; oldd = d; + a = ff(a, b, c, d, x[i + 0], 7, -680876936); d = ff(d, a, b, c, x[i + 1], 12, -389564586); c = ff(c, d, a, b, x[i + 2], 17, 606105819); + b = ff(b, c, d, a, x[i + 3], 22, -1044525330); a = ff(a, b, c, d, x[i + 4], 7, -176418897); d = ff(d, a, b, c, x[i + 5], 12, 1200080426); + c = ff(c, d, a, b, x[i + 6], 17, -1473231341); b = ff(b, c, d, a, x[i + 7], 22, -45705983); a = ff(a, b, c, d, x[i + 8], 7, 1770035416); + d = ff(d, a, b, c, x[i + 9], 12, -1958414417); c = ff(c, d, a, b, x[i + 10], 17, -42063); b = ff(b, c, d, a, x[i + 11], 22, -1990404162); + a = ff(a, b, c, d, x[i + 12], 7, 1804603682); d = ff(d, a, b, c, x[i + 13], 12, -40341101); c = ff(c, d, a, b, x[i + 14], 17, -1502002290); + b = ff(b, c, d, a, x[i + 15], 22, 1236535329); a = gg(a, b, c, d, x[i + 1], 5, -165796510); d = gg(d, a, b, c, x[i + 6], 9, -1069501632); + c = gg(c, d, a, b, x[i + 11], 14, 643717713); b = gg(b, c, d, a, x[i + 0], 20, -373897302); a = gg(a, b, c, d, x[i + 5], 5, -701558691); + d = gg(d, a, b, c, x[i + 10], 9, 38016083); c = gg(c, d, a, b, x[i + 15], 14, -660478335); b = gg(b, c, d, a, x[i + 4], 20, -405537848); + a = gg(a, b, c, d, x[i + 9], 5, 568446438); d = gg(d, a, b, c, x[i + 14], 9, -1019803690); c = gg(c, d, a, b, x[i + 3], 14, -187363961); + b = gg(b, c, d, a, x[i + 8], 20, 1163531501); a = gg(a, b, c, d, x[i + 13], 5, -1444681467); d = gg(d, a, b, c, x[i + 2], 9, -51403784); + c = gg(c, d, a, b, x[i + 7], 14, 1735328473); b = gg(b, c, d, a, x[i + 12], 20, -1926607734); a = hh(a, b, c, d, x[i + 5], 4, -378558); + d = hh(d, a, b, c, x[i + 8], 11, -2022574463); c = hh(c, d, a, b, x[i + 11], 16, 1839030562); b = hh(b, c, d, a, x[i + 14], 23, -35309556); + a = hh(a, b, c, d, x[i + 1], 4, -1530992060); d = hh(d, a, b, c, x[i + 4], 11, 1272893353); c = hh(c, d, a, b, x[i + 7], 16, -155497632); + b = hh(b, c, d, a, x[i + 10], 23, -1094730640); a = hh(a, b, c, d, x[i + 13], 4, 681279174); d = hh(d, a, b, c, x[i + 0], 11, -358537222); + c = hh(c, d, a, b, x[i + 3], 16, -722521979); b = hh(b, c, d, a, x[i + 6], 23, 76029189); a = hh(a, b, c, d, x[i + 9], 4, -640364487); + d = hh(d, a, b, c, x[i + 12], 11, -421815835); c = hh(c, d, a, b, x[i + 15], 16, 530742520); b = hh(b, c, d, a, x[i + 2], 23, -995338651); + a = ii(a, b, c, d, x[i + 0], 6, -198630844); d = ii(d, a, b, c, x[i + 7], 10, 1126891415); c = ii(c, d, a, b, x[i + 14], 15, -1416354905); + b = ii(b, c, d, a, x[i + 5], 21, -57434055); a = ii(a, b, c, d, x[i + 12], 6, 1700485571); d = ii(d, a, b, c, x[i + 3], 10, -1894986606); + c = ii(c, d, a, b, x[i + 10], 15, -1051523); b = ii(b, c, d, a, x[i + 1], 21, -2054922799); a = ii(a, b, c, d, x[i + 8], 6, 1873313359); + d = ii(d, a, b, c, x[i + 15], 10, -30611744); c = ii(c, d, a, b, x[i + 6], 15, -1560198380); b = ii(b, c, d, a, x[i + 13], 21, 1309151649); + a = ii(a, b, c, d, x[i + 4], 6, -145523070); d = ii(d, a, b, c, x[i + 11], 10, -1120210379); c = ii(c, d, a, b, x[i + 2], 15, 718787259); + b = ii(b, c, d, a, x[i + 9], 21, -343485551); a = ad(a, olda); b = ad(b, oldb); c = ad(c, oldc); d = ad(d, oldd); + } + return rh(a) + rh(b) + rh(c) + rh(d); +} diff --git a/src/common/util/decks.ts b/src/common/util/decks.ts index 6a11e4ed..4228d2e9 100644 --- a/src/common/util/decks.ts +++ b/src/common/util/decks.ts @@ -10,7 +10,9 @@ import { instantiateCard } from './cards'; export function cardsInDeck(deck: w.DeckInStore, userCards: w.CardInStore[], sets: w.Set[]): w.CardInStore[] { const set: w.Set | null = deck.setId && sets.find((s) => s.id === deck.setId) || null; const cardPool = set ? set.cards : userCards; - return compact((deck.cardIds || []).map((id) => cardPool.find((c) => c.id === id))); + return compact((deck.cardIds || []).map((id) => + (id.startsWith('builtin/') ? userCards : cardPool).find((c) => c.id === id)) + ); } /** Given a DeckInStore, a user's pool of cards, and a list of sets, diff --git a/src/common/util/formats.tsx b/src/common/util/formats.tsx index f25e86b4..74d92bad 100644 --- a/src/common/util/formats.tsx +++ b/src/common/util/formats.tsx @@ -8,6 +8,7 @@ import { DECK_SIZE } from '../constants'; import defaultState, { bluePlayerState, orangePlayerState } from '../store/defaultGameState'; import * as w from '../types'; +import { verifyIntegrityOfCards } from './cards'; import { nextSeed } from './common'; import { buildCardDraftGroups } from './sets'; @@ -120,6 +121,18 @@ export class GameFormat { return state.gameFormat === this.serialized(); } + /** + * For draft formats, returns a copy of this format filtering out any cards in the format that don't pass integrity checks. + * For non-draft formats, this is just the identify func. + */ + public async checkIntegrity(): Promise { + if (this.requiresDeck) { + return this; + } else { + throw new Error(`Override checkIntegrity() for the ${name} format!`); + } + } + /** Starts a game in this format. * GameFormat's startGame method performs only basic setup, to be overridden by subclasses. */ public startGame( @@ -219,6 +232,12 @@ export class EverythingDraftFormat extends GameFormat { public serialized = (): w.EverythingDraftFormat => ({ _type: 'everythingDraft', cards: this.cards }); + public async checkIntegrity(): Promise { + const { invalidCards } = await verifyIntegrityOfCards(this.cards); + const invalidCardIds = invalidCards.map((c) => c.id); + return new EverythingDraftFormat(this.cards.filter((c) => !invalidCardIds.includes(c.id))); + } + public startGame( state: w.GameState, player: w.PlayerColor, usernames: w.PerPlayer, _decks: w.PerPlayer, options: w.GameOptions, seed: number @@ -301,6 +320,15 @@ export class SetDraftFormat extends GameFormat { this.displayName = `Set Draft: ${set.name} (by ${set.metadata.authorName})${set.metadata.isPublished ? '' : ' (unpublished set)'}`; } + public async checkIntegrity(): Promise { + const { invalidCards } = await verifyIntegrityOfCards(this.set.cards); + const invalidCardIds = invalidCards.map((c) => c.id); + return new SetDraftFormat({ + ...this.set, + cards: this.set.cards.filter((c) => !invalidCardIds.includes(c.id)) + }); + } + public serialized = (): w.SetDraftFormat => ({ _type: 'setDraft', set: this.set }); public rendered = (): React.ReactNode => ( diff --git a/src/common/vocabulary/targets.ts b/src/common/vocabulary/targets.ts index 5e4dc138..49456037 100644 --- a/src/common/vocabulary/targets.ts +++ b/src/common/vocabulary/targets.ts @@ -213,6 +213,7 @@ export default function targets(state: w.GameState, currentObject: w.Object | nu metadata: { source: { type: 'generated' } }, + integrity: [], stats: attributes, type: stringToType(objectType) }; diff --git a/src/server/multiplayer/MultiplayerServerState.ts b/src/server/multiplayer/MultiplayerServerState.ts index 07acc2d4..771d1d62 100644 --- a/src/server/multiplayer/MultiplayerServerState.ts +++ b/src/server/multiplayer/MultiplayerServerState.ts @@ -192,9 +192,9 @@ export default class MultiplayerServerState { } // Make a player host a game with the given name and using the given deck. - public hostGame = (clientID: m.ClientID, name: string, format: m.Format, deck: m.Deck, options: m.GameOptions = {}): void => { + public hostGame = async (clientID: m.ClientID, name: string, format: m.Format, deck: m.Deck, options: m.GameOptions = {}): Promise => { const username = this.getClientUsername(clientID); - const decodedFormat: GameFormat = GameFormat.decode(format); + const decodedFormat: GameFormat = await GameFormat.decode(format).checkIntegrity(); if (decodedFormat.requiresDeck && !decodedFormat.isDeckValid(deck)) { console.warn(`${username} tried to start game ${name} but their deck was invalid for the ${decodedFormat.name} format.`); @@ -211,6 +211,21 @@ export default class MultiplayerServerState { } } + // Like hostGame() but synchronous – only possible for formats that don't require integrity checks – i.e. built-in formats. + public hostGameSync = async (clientID: m.ClientID, name: string, format: m.BuiltInFormat, deck: m.Deck, options: m.GameOptions = {}): Promise => { + const username = this.getClientUsername(clientID); + // TODO reduce duplication with hostGame()? + this.state.waitingPlayers.push({ + id: clientID, + players: [clientID], + name, + format, + deck, + options + }); + console.log(`${username} started game ${name}.`); + } + // Cancel a game that is being hosted. public cancelHostingGame = (clientID: m.ClientID): void => { remove(this.state.waitingPlayers, ((w) => w.players.includes(clientID))); @@ -335,7 +350,7 @@ export default class MultiplayerServerState { } // Add a player to the matchmaking queue. - public joinQueue = (clientID: m.ClientID, format: m.Format, deck: m.Deck): void => { + public joinQueue = (clientID: m.ClientID, format: m.BuiltInFormat, deck: m.Deck): void => { if (this.isClientLoggedIn(clientID) && GameFormat.decode(format).isDeckValid(deck)) { this.state.matchmakingQueue.push({ clientID, format, deck }); } @@ -365,7 +380,7 @@ export default class MultiplayerServerState { const [playerId1, playerId2] = [player1.clientID, player2.clientID]; const gameName = `${this.getClientUsername(playerId1)} vs ${this.getClientUsername(playerId2)}`; - this.hostGame(playerId1, gameName, player1.format, player1.deck); + this.hostGameSync(playerId1, gameName, player1.format, player1.deck); const game = this.joinGame(playerId2, playerId1, player2.deck, { type: 'RANKED' }); if (game) { diff --git a/src/server/multiplayer/multiplayer.d.ts b/src/server/multiplayer/multiplayer.d.ts index 1e29b6ee..0bb1ff69 100644 --- a/src/server/multiplayer/multiplayer.d.ts +++ b/src/server/multiplayer/multiplayer.d.ts @@ -14,6 +14,7 @@ export type CardInStore = w.CardInStore; export type ObfuscatedCard = w.ObfuscatedCard; export type Deck = w.DeckInGame; export type Format = w.Format; +export type BuiltInFormat = w.BuiltInFormat; export type GameState = w.GameState; export type GameOptions = w.GameOptions; export type PerPlayer = w.PerPlayer; @@ -58,7 +59,7 @@ export interface GameWaitingForPlayers { export interface PlayerInQueue { clientID: ClientID deck: Deck - format: Format + format: BuiltInFormat } export interface ServerState { diff --git a/src/server/multiplayer/socket.ts b/src/server/multiplayer/socket.ts index 0f72bf8d..f2bf995f 100644 --- a/src/server/multiplayer/socket.ts +++ b/src/server/multiplayer/socket.ts @@ -1,10 +1,11 @@ import { IncomingMessage, Server } from 'http'; -import { noop } from 'lodash'; +import { isString, noop } from 'lodash'; import * as WebSocket from 'ws'; import { DISCONNECT_FORFEIT_TIME_SECS, ENABLE_OBFUSCATION_ON_SERVER } from '../../common/constants'; import { id as generateID } from '../../common/util/common'; +import { verifyIntegrityOfCards } from '../../common/util/cards'; import * as m from './multiplayer'; import MultiplayerServerState from './MultiplayerServerState'; @@ -204,9 +205,28 @@ export default function launchWebsocketServer(server: Server, path: string): voi } } - function hostGame(clientID: m.ClientID, name: string, format: m.Format, deck: m.Deck, options: m.GameOptions): void { - state.hostGame(clientID, name, format, deck, options); - broadcastInfo(); + async function testDeckValidity(clientID: m.ClientID, deck: m.Deck): Promise { + const { invalidCards } = await verifyIntegrityOfCards(deck.cards); + if (invalidCards.length > 0) { + sendChat( + `You can't use this deck in a multiplayer game because some cards don't pass the integrity check: ${invalidCards.map(c => `"${c.name}"`).join(', ')}. ` + + 'Please open these cards in the Workshop and save them again, or contact the developers if this error message persists.', + [clientID] + ); + return false; + } else { + return true; + } + } + + async function hostGame(clientID: m.ClientID, name: string, format: m.Format, deck: m.Deck, options: m.GameOptions): Promise { + // check deck integrity before letting the player use it in a multiplayer game + if (await testDeckValidity(clientID, deck)) { + await state.hostGame(clientID, name, format, deck, options); + broadcastInfo(); + } else { + sendMessage('ws:CANCEL_HOSTING', undefined, [clientID]); + } } function cancelHostingGame(clientID: m.ClientID): void { @@ -214,22 +234,32 @@ export default function launchWebsocketServer(server: Server, path: string): voi broadcastInfo(); } - function joinGame(clientID: m.ClientID, opponentID: m.ClientID, deck: m.Deck): void { - const game = state.joinGame(clientID, opponentID, deck); - if (game) { - const { decks, format, name, startingSeed, usernames, options } = game; - - sendMessage('ws:GAME_START', { player: 'blue', format, decks, usernames, options, seed: startingSeed }, [clientID]); - sendMessage('ws:GAME_START', { player: 'orange', format, decks, usernames, options, seed: startingSeed }, [opponentID]); - revealVisibleCardsInGame(game); - sendChat(`Entering game ${name} ...`, [clientID, opponentID]); - broadcastInfo(); + async function joinGame(clientID: m.ClientID, opponentID: m.ClientID, deck: m.Deck): Promise { + // check deck integrity before letting the player use it in a multiplayer game + if (await testDeckValidity(clientID, deck)) { + const game = state.joinGame(clientID, opponentID, deck); + if (game) { + const { decks, format, name, startingSeed, usernames, options } = game; + + sendMessage('ws:GAME_START', { player: 'blue', format, decks, usernames, options, seed: startingSeed }, [clientID]); + sendMessage('ws:GAME_START', { player: 'orange', format, decks, usernames, options, seed: startingSeed }, [opponentID]); + revealVisibleCardsInGame(game); + sendChat(`Entering game ${name} ...`, [clientID, opponentID]); + broadcastInfo(); + } } } - function joinQueue(clientID: m.ClientID, format: m.Format, deck: m.Deck): void { - state.joinQueue(clientID, format, deck); - broadcastInfo(); + async function joinQueue(clientID: m.ClientID, format: m.Format, deck: m.Deck): Promise { + if (!isString(format)) { + sendChat('This format is invalid for the matchmaking queue.', [clientID]); + sendMessage('ws:LEAVE_QUEUE', undefined, [clientID]); + } if (await testDeckValidity(clientID, deck)) { + state.joinQueue(clientID, format as m.BuiltInFormat, deck); + broadcastInfo(); + } else { + sendMessage('ws:LEAVE_QUEUE', undefined, [clientID]); + } } function leaveQueue(clientID: m.ClientID): void { diff --git a/test/data/cards.ts b/test/data/cards.ts index bec749da..ff9c69db 100644 --- a/test/data/cards.ts +++ b/test/data/cards.ts @@ -17,7 +17,8 @@ export const cantripCard: w.CardInStore = { text: 'Draw a card.', command: '(function () { actions["draw"](targets["self"](), 1); })', cost: 0, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [] }; export const attackBotCard: w.CardInStore = { @@ -31,7 +32,8 @@ export const attackBotCard: w.CardInStore = { health: 1, speed: 2 }, - abilities: [] + abilities: [], + integrity: [] }; export const wisdomBotCard: w.CardInStore = { @@ -48,7 +50,8 @@ export const wisdomBotCard: w.CardInStore = { text: 'Whenever this robot takes damage, draw a card.', abilities: [ '(function () { setTrigger(triggers["afterDamageReceived"](function () { return targets["thisRobot"](); }), (function () { actions["draw"](targets["self"](), 1); })); })' - ] + ], + integrity: [] }; export const hasteBotCard: w.CardInStore = { @@ -65,7 +68,8 @@ export const hasteBotCard: w.CardInStore = { text: 'Haste', abilities: [ '(function () { setTrigger(triggers["afterPlayed"](function () { return targets["it"](); }), (function () { actions["canMoveAndAttackAgain"](targets["thisRobot"]()); })); })' - ] + ], + integrity: [] }; export const clonerCard: w.CardInStore = { @@ -82,7 +86,8 @@ export const clonerCard: w.CardInStore = { text: 'Activate: return a copy of this robot to your hand', abilities: [ "(function () { setAbility(abilities['activated'](function () { return targets['thisRobot'](); }, \"(function () { actions['moveCardsToHand'](targets['copyOf'](targets['thisRobot']()), targets['self']()); })\")); })" - ] + ], + integrity: [] }; export const investorBotCard: w.CardInStore = { @@ -99,7 +104,8 @@ export const investorBotCard: w.CardInStore = { text: 'When this robot is played, reduce the cost of a card in your hand by 2.', abilities: [ '(function () { setTrigger(triggers["afterPlayed"](function () { return targets["thisRobot"](); }), (function () { actions["modifyAttribute"](targets["choose"](cardsInHand(targets["self"](), "anycard")), "cost", function (x) { return x - 2; }); })); })' - ] + ], + integrity: [] }; export const wrathOfRobotGodCard: w.CardInStore = { @@ -109,7 +115,8 @@ export const wrathOfRobotGodCard: w.CardInStore = { text: 'Destroy all robots.', command: '(function () { actions["destroy"](objectsInPlay("robot")); })', cost: 10, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [] }; export const healthAuraCard: w.CardInStore = { @@ -124,7 +131,8 @@ export const healthAuraCard: w.CardInStore = { text: 'All robots 2 spaces away have +2 health.', abilities: [ '(function () { setAbility(abilities["attributeAdjustment"](function () { return objectsMatchingConditions("robot", [conditions["exactDistanceFrom"](2, targets["thisRobot"]())]); }, "health", function (x) { return x + 2; })); })' - ] + ], + integrity: [] }; export const instantKernelKillerAbilityCard: w.CardInStore = { @@ -137,7 +145,8 @@ export const instantKernelKillerAbilityCard: w.CardInStore = { stats: getBasicStats(), abilities: [ '(function () { setTrigger(triggers["endOfTurn"](function () { return targets["self"](); }), (function () { actions["dealDamage"](objectsMatchingConditions("kernel", [conditions["controlledBy"](targets["opponent"]())]), 21); })); })' - ] + ], + integrity: [] }; export const reinforcementsCard: w.CardInStore = { @@ -147,7 +156,8 @@ export const reinforcementsCard: w.CardInStore = { text: 'Spawn a 1/2/1 robot named "Reinforcements" on each tile adjacent to your kernel.', command: "(function () { actions['spawnObject'](targets['generateCard']('robot', {'attack': 1, 'health': 2, 'speed': 1}, 'Reinforcements'), tilesMatchingConditions([conditions['adjacentTo'](objectsMatchingConditions('kernel', [conditions['controlledBy'](targets['self']())]))])); })", cost: 4, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [] }; export const discardMuncherCard: w.CardInStore = { @@ -165,6 +175,7 @@ export const discardMuncherCard: w.CardInStore = { health: 1, speed: 1 }, + integrity: [] }; export const fairnessField: w.CardInStore = { @@ -180,6 +191,7 @@ export const fairnessField: w.CardInStore = { stats: { health: 5 }, + integrity: [] }; export const looterBotCard: w.CardInStore = { @@ -197,6 +209,7 @@ export const looterBotCard: w.CardInStore = { health: 2, speed: 1 }, + integrity: [] }; export const walkingMonkCard: w.CardInStore = { @@ -214,6 +227,7 @@ export const walkingMonkCard: w.CardInStore = { health: 1, speed: 1 }, + integrity: [] }; export const thresholderCard: w.CardInStore = { @@ -231,6 +245,7 @@ export const thresholderCard: w.CardInStore = { health: 1, speed: 1 }, + integrity: [] }; export const armorerCard: w.CardInStore = { @@ -247,7 +262,8 @@ export const armorerCard: w.CardInStore = { attack: 1, health: 1, speed: 1 - } + }, + integrity: [] }; export const glassHammerCard: w.CardInStore = { @@ -265,6 +281,7 @@ export const glassHammerCard: w.CardInStore = { health: 4, speed: 2 }, + integrity: [] }; @@ -282,6 +299,7 @@ export const countdownClockCard: w.CardInStore = { stats: { health: 1, }, + integrity: [] }; export const drawerCard: w.CardInStore = { @@ -297,6 +315,7 @@ export const drawerCard: w.CardInStore = { stats: { health: 5, }, + integrity: [] }; export const rageCard: w.CardInStore = { @@ -312,6 +331,7 @@ export const rageCard: w.CardInStore = { stats: { health: 3, }, + integrity: [] }; export const librarySchoolCard: w.CardInStore = { @@ -326,7 +346,8 @@ export const librarySchoolCard: w.CardInStore = { type: TYPE_STRUCTURE, stats: { health: 3 - } + }, + integrity: [] }; // Cards with various errors, for testing error handling: @@ -338,7 +359,8 @@ export const errorCard: w.CardInStore = { text: 'Note: This command is hard-coded to throw an error.', command: "(function () { throw 'oops!'; })", cost: 1, - type: TYPE_EVENT + type: TYPE_EVENT, + integrity: [] }; @@ -356,7 +378,8 @@ export const infiniteLoopBotCard: w.CardInStore = { attack: 1, health: 1, speed: 1 - } + }, + integrity: [] }; export const badTriggerBot: w.CardInStore = { @@ -373,7 +396,8 @@ export const badTriggerBot: w.CardInStore = { attack: 1, health: 1, speed: 1 - } + }, + integrity: [] }; export const badTriggerTargetingBot: w.CardInStore = { @@ -390,7 +414,8 @@ export const badTriggerTargetingBot: w.CardInStore = { attack: 1, health: 1, speed: 1 - } + }, + integrity: [] }; export const badAbilityTargetingBot: w.CardInStore = { @@ -407,7 +432,8 @@ export const badAbilityTargetingBot: w.CardInStore = { attack: 1, health: 1, speed: 1 - } + }, + integrity: [] }; export const badAbilityGrantingBot: w.CardInStore = { @@ -424,5 +450,6 @@ export const badAbilityGrantingBot: w.CardInStore = { attack: 1, health: 1, speed: 1 - } + }, + integrity: [] }; diff --git a/test/reducers/collection.spec.ts b/test/reducers/collection.spec.ts index e4d9dd19..95b0cec1 100644 --- a/test/reducers/collection.spec.ts +++ b/test/reducers/collection.spec.ts @@ -101,6 +101,7 @@ describe('Collection reducer', () => { name: 'Test Bot', parserVersion: null, sentences: [], + integrity: [], speed: 1, spriteID: '', text: '', diff --git a/test/server/MultiplayerServerState.spec.ts b/test/server/MultiplayerServerState.spec.ts index 62b263d4..5d7ae976 100644 --- a/test/server/MultiplayerServerState.spec.ts +++ b/test/server/MultiplayerServerState.spec.ts @@ -18,22 +18,26 @@ const initialState: m.SerializedServerState = { queueSize: 0 }; -function expectState(fn: (state: MSS) => void, expectedSerializedState: m.SerializedServerState): void { - const state = new MultiplayerServerState(); - fn(state); - expect(state.serialize()).toEqual(expectedSerializedState); -} -function expectStateFn(fn: (state: MSS) => void, expectedSerializedStateFn: (state: MSS) => m.SerializedServerState): void { - const state = new MultiplayerServerState(); - fn(state); - expect(state.serialize()).toEqual(expectedSerializedStateFn(state)); -} - describe('MultiplayerServerState', () => { const oldConsole = { log: console.log, warn: console.warn }; let dummyWebSocket: WebSocket; let warning = ''; + async function expectState(fn: (state: MSS) => Promise, expectedSerializedState: m.SerializedServerState, expectedWarning?: string): Promise { + const state = new MultiplayerServerState(); + await fn(state); + expect(state.serialize()).toEqual(expectedSerializedState); + if (expectedWarning) { + expect(warning).toEqual(expectedWarning); + } + } + + async function expectStateFn(fn: (state: MSS) => Promise, expectedSerializedStateFn: (state: MSS) => m.SerializedServerState): Promise { + const state = new MultiplayerServerState(); + await fn(state); + expect(state.serialize()).toEqual(expectedSerializedStateFn(state)); + } + beforeAll(() => { dummyWebSocket = new MockSocket('ws://null') as any as WebSocket; console.log = noop; @@ -45,13 +49,14 @@ describe('MultiplayerServerState', () => { console.warn = oldConsole.warn; }); - it('should return the initial state', () => { - expectState(noop, initialState); + it('should return the initial state', async () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + await await expectState(async () => { }, initialState); }); describe('[Connect/disconnect]', () => { - it('should be able to connect a logged-in player', () => { - expectState((state: MSS) => { + it('should be able to connect a logged-in player', async () => { + await expectState(async (state: MSS) => { state.connectClient('loggedInClient', dummyWebSocket); state.setClientUserData('loggedInClient', { uid: 'loggedInClientUid', displayName: 'loggedInClientUsername' }); }, { @@ -64,19 +69,18 @@ describe('MultiplayerServerState', () => { }); }); - it('should be able to connect a guest player', () => { - expectState((state: MSS) => { + it('should be able to connect a guest player', async () => { + await expectState(async (state: MSS) => { state.connectClient('guestClient', dummyWebSocket); }, { ...initialState, playersOnline: ['guestClient'], playersInLobby: ['guestClient'] - } - ); + }); }); - it('should be able to disconnect a player', () => { - expectState((state: MSS) => { + it('should be able to disconnect a player', async () => { + await expectState(async (state: MSS) => { state.connectClient('guestClient', dummyWebSocket); state.disconnectClient('guestClient'); }, initialState); @@ -84,11 +88,11 @@ describe('MultiplayerServerState', () => { }); describe('[Hosting/joining games]', () => { - it('should be able to host a game', () => { - expectState((state: MSS) => { + it('should be able to host a game', async () => { + await expectState(async (state: MSS) => { state.connectClient('host', dummyWebSocket); state.setClientUserData('host', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); }, { ...initialState, playersOnline: ['host'], @@ -107,10 +111,10 @@ describe('MultiplayerServerState', () => { }); }); - it('should be able to host a game as a guest', () => { - expectState((state: MSS) => { + it('should be able to host a game as a guest', async () => { + await expectState(async (state: MSS) => { state.connectClient('host', dummyWebSocket); - state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); }, { ...initialState, playersOnline: ['host'], @@ -128,25 +132,24 @@ describe('MultiplayerServerState', () => { }); }); - it('should NOT be able to host a game with an invalid deck', () => { - expectState((state: MSS) => { + it('should NOT be able to host a game with an invalid deck', async () => { + await expectState(async (state: MSS) => { state.connectClient('host', dummyWebSocket); state.setClientUserData('host', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('host', 'My Game', 'normal', emptyDeck); + await state.hostGame('host', 'My Game', 'normal', emptyDeck); }, { ...initialState, playersOnline: ['host'], playersInLobby: ['host'], userData: { host: { uid: 'hostId', displayName: 'hostName' } } - }); - expect(warning).toEqual('hostName tried to start game My Game but their deck was invalid for the normal format.'); + }, 'hostName tried to start game My Game but their deck was invalid for the normal format.'); }); - it('should be able to cancel hosting a game', () => { - expectState((state: MSS) => { + it('should be able to cancel hosting a game', async () => { + await expectState(async (state: MSS) => { state.connectClient('host', dummyWebSocket); state.setClientUserData('host', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); state.cancelHostingGame('host'); }, { ...initialState, @@ -156,12 +159,12 @@ describe('MultiplayerServerState', () => { }); }); - it('should be able to join a hosted game (even as a guest)', () => { - expectStateFn((state: MSS) => { + it('should be able to join a hosted game (even as a guest)', async () => { + await expectStateFn(async (state: MSS) => { state.connectClient('host', dummyWebSocket); state.connectClient('guest', dummyWebSocket); state.setClientUserData('host', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); state.joinGame('guest', 'host', defaultDecks[1]); }, (state: MSS) => ({ ...initialState, @@ -176,12 +179,12 @@ describe('MultiplayerServerState', () => { })); }); - it('should NOT be able to join a game with an invalid deck', () => { - expectState((state: MSS) => { + it('should NOT be able to join a game with an invalid deck', async () => { + await expectState(async (state: MSS) => { state.connectClient('host', dummyWebSocket); state.connectClient('guest', dummyWebSocket); state.setClientUserData('host', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); state.joinGame('guest', 'host', emptyDeck); }, { ...initialState, @@ -198,17 +201,16 @@ describe('MultiplayerServerState', () => { players: ['host'] } ] - }); - expect(warning).toEqual('Guest_guest was unable to join hostName\'s game.'); + }, 'Guest_guest was unable to join hostName\'s game.'); }); - it('should be able to join an active game as a spectator', () => { - expectStateFn((state: MSS) => { + it('should be able to join an active game as a spectator', async () => { + await expectStateFn(async (state: MSS) => { state.connectClient('host', dummyWebSocket); state.connectClient('guest', dummyWebSocket); state.connectClient('spectator', dummyWebSocket); state.setClientUserData('host', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('host', 'My Game', 'normal', defaultDecks[0]); state.joinGame('guest', 'host', defaultDecks[1]); state.spectateGame('spectator', (state.lookupGameByClient('guest') as m.Game).id); }, (state: MSS) => ({ @@ -227,8 +229,8 @@ describe('MultiplayerServerState', () => { }); describe('[Queuing]', () => { - it('should be able to join the unranked queue', () => { - expectState((state: MSS) => { + it('should be able to join the unranked queue', async () => { + await expectState(async (state: MSS) => { state.connectClient('player', dummyWebSocket); state.setClientUserData('player', { uid: 'playerId', displayName: 'playerName' }); state.joinQueue('player', 'normal', defaultDecks[0]); @@ -241,8 +243,8 @@ describe('MultiplayerServerState', () => { }); }); - it('should NOT be able to join the unranked queue as a guest', () => { - expectState((state: MSS) => { + it('should NOT be able to join the unranked queue as a guest', async () => { + await expectState(async (state: MSS) => { state.connectClient('player', dummyWebSocket); state.joinQueue('player', 'normal', defaultDecks[0]); }, { @@ -252,8 +254,8 @@ describe('MultiplayerServerState', () => { }); }); - it('should NOT be able to join the unranked queue with an invalid deck', () => { - expectState((state: MSS) => { + it('should NOT be able to join the unranked queue with an invalid deck', async () => { + await expectState(async (state: MSS) => { state.connectClient('player', dummyWebSocket); state.joinQueue('player', 'normal', emptyDeck); }, { @@ -263,8 +265,8 @@ describe('MultiplayerServerState', () => { }); }); - it('should be able to leave the unranked queue', () => { - expectState((state: MSS) => { + it('should be able to leave the unranked queue', async () => { + await expectState(async (state: MSS) => { state.connectClient('player', dummyWebSocket); state.setClientUserData('player', { uid: 'playerId', displayName: 'playerName' }); state.joinQueue('player', 'normal', defaultDecks[0]); @@ -278,9 +280,9 @@ describe('MultiplayerServerState', () => { }); }); - it('should be matched as soon as there is more than one player in the unranked queue for a given format', () => { + it('should be matched as soon as there is more than one player in the unranked queue for a given format', async () => { // TODO this behavior will change once there is "real" matchmaking. - expectStateFn((state: MSS) => { + await expectStateFn(async (state: MSS) => { state.connectClient('player1', dummyWebSocket); state.setClientUserData('player1', { uid: 'playerId1', displayName: 'playerName1' }); state.joinQueue('player1', 'normal', defaultDecks[0]); @@ -306,8 +308,8 @@ describe('MultiplayerServerState', () => { })); }); - it('should NOT be matched against a player in a different format', () => { - expectState((state: MSS) => { + it('should NOT be matched against a player in a different format', async () => { + await expectState(async (state: MSS) => { state.connectClient('player1', dummyWebSocket); state.setClientUserData('player1', { uid: 'playerId1', displayName: 'playerName1' }); state.joinQueue('player1', 'normal', defaultDecks[0]); @@ -329,12 +331,12 @@ describe('MultiplayerServerState', () => { }); describe('[Gameplay and game end]', () => { - it('should store game actions and maintain a copy of game state', () => { + it('should store game actions and maintain a copy of game state', async () => { const state = new MultiplayerServerState(); state.connectClient('player1', dummyWebSocket); state.connectClient('player2', dummyWebSocket); state.setClientUserData('player1', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('player1', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('player1', 'My Game', 'normal', defaultDecks[0]); state.joinGame('player2', 'player1', defaultDecks[1]); let game: m.Game = state.lookupGameByClient('player1')!; @@ -352,7 +354,7 @@ describe('MultiplayerServerState', () => { expect([game.state.players.orange.hand.length, game.state.players.blue.hand.length]).toEqual([3, 3]); }); - it('should detect endgame conditions', () => { + it('should detect endgame conditions', async () => { const state = new MultiplayerServerState(); const storeGameResultFn: jest.Mock = jest.fn(); state.storeGameResult = storeGameResultFn; @@ -360,7 +362,7 @@ describe('MultiplayerServerState', () => { state.connectClient('player1', dummyWebSocket); state.connectClient('player2', dummyWebSocket); state.setClientUserData('player1', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('player1', 'My Game', 'normal', kernelKillerDeck); + await state.hostGame('player1', 'My Game', 'normal', kernelKillerDeck); state.joinGame('player2', 'player1', defaultDecks[1]); state.appendGameAction('player1', gameActions.placeCard('3,-1,-2', 0) as m.Action); expect(storeGameResultFn.mock.calls.length).toBe(0); @@ -368,15 +370,15 @@ describe('MultiplayerServerState', () => { expect(storeGameResultFn.mock.calls.length).toBe(1); }); - it('should end the game if a player leaves the game', () => { - expectState((state: MSS) => { + it('should end the game if a player leaves the game', async () => { + await expectState(async (state: MSS) => { const storeGameResultFn: jest.Mock = jest.fn(); state.storeGameResult = storeGameResultFn; state.connectClient('player1', dummyWebSocket); state.connectClient('player2', dummyWebSocket); state.setClientUserData('player1', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('player1', 'My Game', 'normal', defaultDecks[0]); + await state.hostGame('player1', 'My Game', 'normal', defaultDecks[0]); state.joinGame('player2', 'player1', defaultDecks[1]); expect(storeGameResultFn.mock.calls.length).toBe(0); state.leaveGame('player2'); @@ -391,7 +393,7 @@ describe('MultiplayerServerState', () => { }); describe('getCardsToReveal()', () => { - function startGame(player1Deck: m.Deck = defaultDecks[0], player2Deck: m.Deck = defaultDecks[0]): MSS { + async function startGame(player1Deck: m.Deck = defaultDecks[0], player2Deck: m.Deck = defaultDecks[0]): Promise { const state = new MultiplayerServerState(); state.connectClient('player1', dummyWebSocket); @@ -399,15 +401,15 @@ describe('MultiplayerServerState', () => { state.connectClient('spectator', dummyWebSocket); state.setClientUserData('player1', { uid: 'hostId', displayName: 'hostName' }); - state.hostGame('player1', 'My Game', 'normal', player1Deck); + await state.hostGame('player1', 'My Game', 'normal', player1Deck); state.joinGame('player2', 'player1', player2Deck); state.spectateGame('spectator', (state.lookupGameByClient('player1') as m.Game).id); return state; } - it('should only reveal cards in a player\'s hand to that player', () => { - const state = startGame(); + it('should only reveal cards in a player\'s hand to that player', async () => { + const state = await startGame(); // player1 is orange. expect(state.getCardsToReveal('player1').blue.hand.filter((c: m.Card) => c.id === 'obfuscated').length).toEqual(2); expect(state.getCardsToReveal('player1').orange.hand.filter((c: m.Card) => c.id !== 'obfuscated').length).toEqual(2); @@ -416,14 +418,14 @@ describe('MultiplayerServerState', () => { expect(state.getCardsToReveal('player2').orange.hand.filter((c: m.Card) => c.id === 'obfuscated').length).toEqual(2); }); - it('should not reveal any cards in hand to spectators', () => { - const state = startGame(); + it('should not reveal any cards in hand to spectators', async () => { + const state = await startGame(); expect(state.getCardsToReveal('spectator').blue.hand.filter((c: m.Card) => c.id === 'obfuscated').length).toEqual(2); expect(state.getCardsToReveal('spectator').orange.hand.filter((c: m.Card) => c.id === 'obfuscated').length).toEqual(2); }); - it('should reveal robot cards when they are about to be played', () => { - const state = startGame(botsOnlyDeck, botsOnlyDeck); + it('should reveal robot cards when they are about to be played', async () => { + const state = await startGame(botsOnlyDeck, botsOnlyDeck); const action: [m.Action, m.ClientID] = [gameActions.placeCard('3,-1,-2', 0) as m.Action, 'player1']; // player1 is orange. @@ -435,8 +437,8 @@ describe('MultiplayerServerState', () => { expect(state.getCardsToReveal('player2', action).orange.hand.filter((c: m.Card) => c.id === 'obfuscated').length).toEqual(1); }); - it('should reveal event cards when they are about to be played', () => { - const state = startGame(eventsOnlyDeck, eventsOnlyDeck); + it('should reveal event cards when they are about to be played', async () => { + const state = await startGame(eventsOnlyDeck, eventsOnlyDeck); state.appendGameAction('player1', gameActions.setSelectedCard(0, 'orange') as m.Action); const action: [m.Action, m.ClientID] = [gameActions.setSelectedTile('3,-1,-2', 'orange') as m.Action, 'player1']; @@ -449,8 +451,8 @@ describe('MultiplayerServerState', () => { expect(state.getCardsToReveal('player2', action).orange.hand.filter((c: m.Card) => c.id === 'obfuscated').length).toEqual(1); }); - it('should reveal cards in the discard pile to all clients', () => { - const state = startGame(eventsOnlyDeck, eventsOnlyDeck); + it('should reveal cards in the discard pile to all clients', async () => { + const state = await startGame(eventsOnlyDeck, eventsOnlyDeck); state.appendGameAction('player1', gameActions.setSelectedCard(0, 'orange') as m.Action); state.appendGameAction('player1', gameActions.setSelectedTile('3,-1,-2', 'orange') as m.Action); @@ -459,4 +461,6 @@ describe('MultiplayerServerState', () => { }); }); }); + + // TODO tests for card integrity verification? }); diff --git a/test/store/cards.spec.ts b/test/store/cards.spec.ts index 4b4ad281..1be5d775 100644 --- a/test/store/cards.spec.ts +++ b/test/store/cards.spec.ts @@ -19,6 +19,7 @@ describe('Built-in cards', () => { type: TYPE_ROBOT, card: { metadata: { source: { type: 'builtin' } as w.CardSource }, + integrity: [], id: 'Test', name: 'Test', type: TYPE_ROBOT, diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 4b763492..fafbc81d 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -266,6 +266,7 @@ export const action = ( command: w.StringRepresentationOf<(state: w.GameState) => any> | Array any>> ): w.CardInStore => ({ metadata: { source: { type: 'builtin' } as w.CardSource }, + integrity: [], id: text, name: text, text,