Skip to content

Commit

Permalink
feat: revamp prerequisite processor. closes #458
Browse files Browse the repository at this point in the history
  • Loading branch information
ldeluigi committed Nov 11, 2024
1 parent cc1c534 commit 1844e17
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 99 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@spacecowmedia/spellbook-client": "^3.12.0",
"@spacecowmedia/spellbook-client": "^3.13.0",
"canvas": "^2.11.2",
"debounce": "^2.2.0",
"markdown-to-jsx": "^7.5.0",
Expand Down
21 changes: 12 additions & 9 deletions src/components/combo/PrerequisiteList/PrerequisiteList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ComboPrerequisites } from '../../../lib/types';
import TextWithMagicSymbol from '../../layout/TextWithMagicSymbol/TextWithMagicSymbol';
import Icon from '../../layout/Icon/Icon';
import Icon, { SpellbookIcon } from '../../layout/Icon/Icon';
import { addPeriod } from '../../../lib/addPeriod';
import { CardInVariant, TemplateInVariant } from '@spacecowmedia/spellbook-client';
import React from 'react';
Expand All @@ -14,14 +14,15 @@ type Props = {
templatesInCombo?: TemplateInVariant[];
};

const ICON_MAP = {
const ICON_MAP: Record<string, SpellbookIcon> = {
B: 'battlefield',
C: 'commandZone',
G: 'graveyard',
H: 'hand',
L: 'library',
E: 'exile',
};

const PrerequisiteList: React.FC<Props> = ({
prerequisites,
className,
Expand All @@ -36,13 +37,15 @@ const PrerequisiteList: React.FC<Props> = ({
<h2 className="font-bold text-xl mb-2">Prerequisites</h2>
<ol className="list-inside">
{prerequisites.map((prereq, index) => (
<li key={`${prereq.zones}-${index}`}>
{ICON_MAP[prereq.zones as keyof typeof ICON_MAP] && (
<>
<Icon name={ICON_MAP[prereq.zones as keyof typeof ICON_MAP] as any} />
&nbsp;
</>
)}
<li key={`${prereq.zones.join('')}-${index}`}>
{prereq.zones
.filter((z) => ICON_MAP[z])
.map((z) => (
<span key={`${prereq.zones.join('')}-${index}-${z}`}>
<Icon name={ICON_MAP[z]} />
&nbsp;
</span>
))}
<TextWithMagicSymbol
text={addPeriod(prereq.description)}
cardsInCombo={cardsInCombo}
Expand Down
177 changes: 93 additions & 84 deletions src/lib/prerequisitesProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ComboPrerequisites, getName, getNameBeforeComma } from './types';
import { ComboPrerequisites, getName, getNameBeforeComma, getTypes } from './types';
import { CardInVariant, TemplateInVariant, Variant } from '@spacecowmedia/spellbook-client';

const NONPERMANENT_TYPES = ['instant', 'sorcery'];

const ZONE_MAP = {
H: 'in hand',
B: 'on the battlefield',
Expand All @@ -10,7 +12,7 @@ const ZONE_MAP = {
E: 'in exile',
};

const comaAndOrJoin = (input: string[], joiner = 'and') => {
function comaAndOrJoin(input: string[], joiner = 'and') {
if (input.length === 0) {
return '';
}
Expand All @@ -21,39 +23,42 @@ const comaAndOrJoin = (input: string[], joiner = 'and') => {
return input.join(` ${joiner} `);
}
return `${input.slice(0, -1).join(', ')}, ${joiner} ${input.slice(-1)}`;
};
}

const getZoneStateMap = (card: CardInVariant | TemplateInVariant) => {
const output: Record<string, string> = {};
function getCardState(card: CardInVariant | TemplateInVariant) {
const output: Array<{ zone: keyof typeof ZONE_MAP; state: string }> = [];
if (card.battlefieldCardState) {
output['B'] = card.battlefieldCardState;
output.push({ zone: 'B', state: card.battlefieldCardState });
}
if (card.exileCardState) {
output['E'] = card.exileCardState;
output.push({ zone: 'E', state: card.exileCardState });
}
if (card.graveyardCardState) {
output['G'] = card.graveyardCardState;
output.push({ zone: 'G', state: card.graveyardCardState });
}
if (card.libraryCardState) {
output['L'] = card.libraryCardState;
output.push({ zone: 'L', state: card.libraryCardState });
}
return output;
};
if (output.length === 0) {
return '';
}
if (output.length === 1) {
return output[0].state;
}
return comaAndOrJoin(
output.map((o) => `${o.state} ${ZONE_MAP[o.zone]}`),
'or',
);
}

export const getPrerequisiteList = (variant: Variant): ComboPrerequisites[] => {
let output: ComboPrerequisites[] = [];
type Card = (CardInVariant | TemplateInVariant) & { name: string; type: string };

// TODO: consider DFCs

const cardsAndTemplates: Array<CardInVariant | TemplateInVariant> = (
variant.uses as Array<CardInVariant | TemplateInVariant>
)
export const getPrerequisiteList = (variant: Variant): ComboPrerequisites[] => {
const cardsAndTemplates: Array<Card> = (variant.uses as Array<CardInVariant | TemplateInVariant>)
.concat(variant.requires)
.map((card) => {
if ('card' in card) {
return { ...card, name: card.card.name };
} else {
return { ...card, name: card.template.name };
}
});
.map((card) => ({ ...card, name: getName(card), type: getTypes(card) }));

// Count if any coma split card names exist more than once
const cardNameCountMap = cardsAndTemplates.reduce((acc: Record<string, number>, card) => {
Expand All @@ -64,103 +69,107 @@ export const getPrerequisiteList = (variant: Variant): ComboPrerequisites[] => {

// Map card names to coma split card names if they only exist once
const cardNameMap = cardsAndTemplates.reduce((acc: Record<string, string>, card) => {
const name = getName(card);
const split = getNameBeforeComma(card);
acc[name] = cardNameCountMap[split] === 1 ? split : name;
acc[card.name] = cardNameCountMap[split] === 1 ? split : card.name;
return acc;
}, {});

// Handle any multi-zone cards
const multiZoneCards = cardsAndTemplates.filter((card) => card.zoneLocations.length > 1);
for (const card of multiZoneCards.sort((a, b) => getName(a).localeCompare(getName(b)))) {
let cardString = `${card.quantity > 1 ? `${card.quantity}x ` : ''}${cardNameMap[getName(card)]} `;
// Handle zones descriptions
const zonesToDescriptions: Record<string, string> = cardsAndTemplates.reduce((acc: Record<string, string>, card) => {
const zoneKey = card.zoneLocations.join('');
if (acc[zoneKey]) {
return acc;
}
if (Object.keys(ZONE_MAP).length === card.zoneLocations.length) {
cardString += 'in any zone';
acc[zoneKey] = 'in any zone';
} else {
cardString += card.zoneLocations.map((zone) => ZONE_MAP[zone as keyof typeof ZONE_MAP]).join(' or ');
}
const combinedStateString = comaAndOrJoin(Object.values(getZoneStateMap(card)));
if (combinedStateString) {
cardString += ` (${combinedStateString})`;
acc[zoneKey] = card.zoneLocations.map((zone) => ZONE_MAP[zone as keyof typeof ZONE_MAP]).join(' or ');
}
cardString += '. ';
output.push({ zones: 'multi', description: cardString });
}
const singleZoneCards = cardsAndTemplates.filter((card) => card.zoneLocations.length === 1);

const zoneGroups: {
cardNames: string[];
cardState: string;
zone: keyof typeof ZONE_MAP;
}[] = [];
return acc;
}, {});

// Sort cards into groups by zone
for (const zoneKey in ZONE_MAP) {
const zoneCards = singleZoneCards.filter((card) => card.zoneLocations[0] === zoneKey);
if (zoneCards.length === 0) {
continue;
}
// Pull out the card state for the current zone and if it exists store the card name in an array with the key of the card state string so it can be grouped with cards that match its state
const stateMap = zoneCards.reduce((acc: Record<string, string[]>, card) => {
const cardState = getZoneStateMap(card)[zoneKey];
if (cardState) {
const name = getName(card);
acc[cardState] = acc[cardState] ? acc[cardState].concat([cardNameMap[name]]) : [cardNameMap[name]];
let zoneGroups: Array<{ cards: Card[]; zones: string[]; cardState: string }> = [];
for (const zoneKey of Object.keys(zonesToDescriptions).toSorted((a, b) =>
a.length == b.length
? a
.split('')
.map((c) => Object.keys(ZONE_MAP).indexOf(c).toString())
.join('')
.localeCompare(
b
.split('')
.map((c) => Object.keys(ZONE_MAP).indexOf(c).toString())
.join(''),
)
: a.length - b.length,
)) {
const zoneCards = cardsAndTemplates.filter((card) => card.zoneLocations.join('') === zoneKey);
const reverseCardStateMap: Record<string, Card[]> = zoneCards.reduce((acc: Record<string, Card[]>, card) => {
const state = getCardState(card);
if (state) {
if (acc[state]) {
acc[state].push(card);
} else {
acc[state] = [card];
}
}
return acc;
}, {});
let cardStateStrings: string[] = [];
for (const stateKey in stateMap) {
let cardStateString = comaAndOrJoin(stateMap[stateKey]);
if (stateKey) {
cardStateString += ` ${stateKey}`;
}
cardStateStrings.push(cardStateString);
}
let cardState = comaAndOrJoin(cardStateStrings);
if (cardState) {
cardState = ` (${cardState})`;
}
const cardStateStrings = Object.keys(reverseCardStateMap).map(
(stateKey) =>
(zoneCards.length > 1 && reverseCardStateMap[stateKey].length < zoneCards.length
? comaAndOrJoin(reverseCardStateMap[stateKey].map((card) => cardNameMap[card.name])) + ' '
: '') + stateKey,
);
const cardState = comaAndOrJoin(cardStateStrings);
zoneGroups.push({
cardNames: zoneCards.map(
(card) => `${card.quantity > 1 ? `${card.quantity}x ` : ''}${cardNameMap[getName(card)]}`,
),
zone: zoneKey as keyof typeof ZONE_MAP,
cards: zoneCards,
zones: zoneKey.split(''),
cardState,
});
}

let index = 0;
for (const zoneGroup of zoneGroups.sort((a, b) => (a.cardNames.length > b.cardNames.length ? 1 : -1))) {
const cards = zoneGroup.cardNames;
const output: ComboPrerequisites[] = [];
for (const zoneGroup of zoneGroups.sort((a, b) => a.cards.length - b.cards.length)) {
const cards = zoneGroup.cards.map(
(card) => `${card.quantity > 1 ? `${card.quantity}x ` : ''}${cardNameMap[card.name]}`,
);
// If this is the last zone group, and it has more than 2 cards, swap card names for combinations string
if (index === zoneGroups.length - 1 && cards.length > 2) {
const stateBit = zoneGroup.cardState ? ` (${zoneGroup.cardState})` : '';
if (index === zoneGroups.length - 1 && zoneGroup.cards.length > 2) {
const otherGroups = zoneGroups.slice(0, -1);
const otherGroupsHaveAPermanent = otherGroups.some((g) =>
g.cards.some((c) => !NONPERMANENT_TYPES.some((t) => c.type.toLowerCase().includes(t))),
);
const thisGroupIsOfPermanents = zoneGroup.cards.every((c) => !NONPERMANENT_TYPES.some((t) => c.type.includes(t)));
const description = `All${zoneGroups.length > 1 && (!thisGroupIsOfPermanents || otherGroupsHaveAPermanent) ? ' other' : ''} ${thisGroupIsOfPermanents ? 'permanents' : 'cards'} ${zonesToDescriptions[zoneGroup.zones.join('')]}${stateBit}.`;
output.push({
zones: zoneGroup.zone,
description: `All${zoneGroups.length + multiZoneCards.length > 1 ? ' other' : ''} ${zoneGroup.zone === 'B' ? 'permanents' : 'cards'} ${ZONE_MAP[zoneGroup.zone]}${zoneGroup.cardState}`,
zones: zoneGroup.zones,
description: description,
});
} else {
// Otherwise just list the cards
output.push({
zones: zoneGroup.zone,
zones: zoneGroup.zones,
description:
(cards.length < 3 ? cards.join(' and ') : cards.slice(0, -1).join(', ') + ' and ' + cards.slice(-1)) +
' ' +
ZONE_MAP[zoneGroup.zone] +
zoneGroup.cardState,
zonesToDescriptions[zoneGroup.zones.join('')] +
stateBit +
'.',
});
}
index++;
}

// Add any other prerequisites
if (variant.otherPrerequisites) {
variant.otherPrerequisites
.split(/\.\s+/gi)
.forEach((prereq) => output.push({ zones: 'other', description: prereq }));
.forEach((prereq) => output.push({ zones: ['other'], description: prereq }));
}
if (variant.manaNeeded) {
output.push({ zones: 'mana', description: `${variant.manaNeeded} available` });
output.push({ zones: ['mana'], description: `${variant.manaNeeded} available` });
}

return output;
Expand Down
6 changes: 5 additions & 1 deletion src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CardInVariant, TemplateInVariant } from '@spacecowmedia/spellbook-clien

export type ComboPrerequisites = {
/* Zone either H, B, C, G, L, E or multiple of them */
zones: string;
zones: string[];
/* Additional description of the prerequisite */
description: string;
};
Expand All @@ -18,3 +18,7 @@ export function getName(card: CardInVariant | TemplateInVariant): string {
export function getNameBeforeComma(card: CardInVariant | TemplateInVariant): string {
return 'card' in card ? card.card.name.split(', ')[0] : card.template.name;
}

export function getTypes(card: CardInVariant | TemplateInVariant): string {
return 'card' in card ? card.card.typeLine : '';
}
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -925,10 +925,10 @@
resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df"
integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==

"@spacecowmedia/spellbook-client@^3.12.0":
version "3.12.0"
resolved "https://npm.pkg.github.com/download/@spacecowmedia/spellbook-client/3.12.0/eed2cefbd4997b6a151cd9addbd15e8d3d37fdc7#eed2cefbd4997b6a151cd9addbd15e8d3d37fdc7"
integrity sha512-Zo56hoaYvJgKMBjOjoXvNdtlzSe2SwoWGSQqK+5Hm3A91HoSbMCjcoxDHpBJAP5d83qBbmoP3ZnG3PDu067Pkw==
"@spacecowmedia/spellbook-client@^3.13.0":
version "3.13.0"
resolved "https://npm.pkg.github.com/download/@spacecowmedia/spellbook-client/3.13.0/66da5548d94ad8a7e066e5ad0c60bc571e722e17#66da5548d94ad8a7e066e5ad0c60bc571e722e17"
integrity sha512-v142qhtUM+jFTMqw44M983QakgvDPRGPahdV7tGKcQdy/vf0bvbJQX3atHUbpaZbOCRT4EGstTp4rYvscWd73w==

"@swc/counter@0.1.3":
version "0.1.3"
Expand Down

0 comments on commit 1844e17

Please sign in to comment.