Skip to content

Commit 99ec51b

Browse files
authored
Merge pull request #689 from distributive/new-api-banlist-page
New api ban list page
2 parents 011305f + 959c810 commit 99ec51b

File tree

6 files changed

+313
-164
lines changed

6 files changed

+313
-164
lines changed
Lines changed: 190 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,190 @@
1-
{% extends '/layout.html.twig' %}
2-
3-
{% block title %}Ban Lists{% endblock %}
4-
5-
{% block head %}
6-
7-
{% endblock %}
8-
9-
{% block body %}
10-
<div class="container">
11-
12-
<h1>{{ block('title') }}</h1>
13-
14-
<h2>Startup Format</h2>
15-
<div id="list">
16-
<div class="row">
17-
<div class="col-sm-12">
18-
<p>No cards are currently banned in Startup. Have a blast!</p>
19-
</div>
20-
</div>
21-
</div>
22-
23-
<h2>Standard Format</h2>
24-
<div id="list">
25-
{% for banlist in banlists %}
26-
<div class="row">
27-
<div class="col-sm-12">
28-
<h3><a name="{{ banlist.code }}"></a>{{ banlist.name }} {% if banlist.active %}(active){% endif %}</h3>
29-
<p>{{ banlist.num_cards }} cards. Start Date {{ banlist.start_date|date("Y-m-d") }}</p>
30-
</div>
31-
</div>
32-
33-
<div class="row">
34-
<div class="col-sm-5">
35-
<h3>Corp Cards</h3>
36-
<ul>
37-
{% for verdict in banlist.cards|keys|sort %}
38-
<li><strong>{{ verdict }}</strong>
39-
<ul>
40-
{% if verdict == 'Banned' and banlist.all_currents_banned %}
41-
<li>All cards with <strong><a href="{{ path('cards_find',{type:'find',_locale:app.request.locale,'view':'list','q':'s:current d:corp'}) }}">Current</a></strong> subtype.</li>
42-
{% endif %}
43-
{% for card in banlist.cards[verdict]|sort((a, b) => a.card.title <=> b.card.title) %}
44-
{% if card.card.side.code == 'corp' %}
45-
{% if banlist.all_currents_banned %}
46-
{% if "Current" not in card.card.keywords %}
47-
<li><a href="{{ path('cards_zoom', {card_code:card.card.code}) }}">{{ card.card.title }}</a></li>
48-
{% endif %}
49-
{% else %}
50-
<li><a href="{{ path('cards_zoom', {card_code:card.card.code}) }}">{{ card.card.title }}</a></li>
51-
{% endif %}
52-
{% endif %}
53-
{% endfor %}
54-
</ul>
55-
</li>
56-
{% endfor %}
57-
</ul>
58-
</div>
59-
60-
<div class="col-sm-7">
61-
<h3>Runner Cards</h3>
62-
<ul>
63-
{% for verdict in banlist.cards|keys|sort %}
64-
<li><strong>{{ verdict }}</strong>
65-
<ul>
66-
{% if verdict == 'Banned' and banlist.all_currents_banned %}
67-
<li>All cards with <strong><a href="{{ path('cards_find',{type:'find',_locale:app.request.locale,'view':'list','q':'s:current d:runner'}) }}">Current</a></strong> subtype.</li>
68-
{% endif %}
69-
{% for card in banlist.cards[verdict]|sort((a, b) => a.card.title <=> b.card.title) %}
70-
{% if card.card.side.code == 'runner' %}
71-
{% if banlist.all_currents_banned %}
72-
{% if "Current" not in card.card.keywords %}
73-
<li><a href="{{ path('cards_zoom', {card_code:card.card.code}) }}">{{ card.card.title }}</a></li>
74-
{% endif %}
75-
{% else %}
76-
<li><a href="{{ path('cards_zoom', {card_code:card.card.code}) }}">{{ card.card.title }}</a></li>
77-
{% endif %}
78-
{% endif %}
79-
{% endfor %}
80-
</ul>
81-
</li>
82-
{% endfor %}
83-
</ul>
84-
</div>
85-
</div>
86-
{% endfor %}
87-
</div>
88-
</div>
89-
{% endblock %}
1+
{% extends '/layout.html.twig' %}
2+
3+
{% block title %}Ban Lists{% endblock %}
4+
5+
{% block body %}
6+
{% include '/Scripts/api.html.twig' %}
7+
<div class="container" id="banlists">
8+
<h1>{{ block('title') }}</h1>
9+
<div>
10+
<p>There are currently three official <a href="{{ path('formats') }}">formats</a> supported by NISEI: Startup, Standard, and Eternal. This page displays the ban lists for each.</p>
11+
<h2>Explanation</h2>
12+
<ul>
13+
<li><b>Banned:</b> You cannot include any copies of a banned card in your deck.</li>
14+
<li><b>Points:</b> Including any number of copies of a card with points in Eternal adds points to your deck. Eternal decks must have 7 points or fewer.</li>
15+
<li><b>Restricted (deprecated):</b> You may include up to a full playset of only one restricted card.</li>
16+
<li><b>Universal Influence (deprecated):</b> Cards with universal influence cost additional influence to include in a deck.</li>
17+
<li><b>Identity Influence Reduction (deprecated):</b> These cards reduce your identity's influence limit by 1 for each copy (to a minimum of 1).</li>
18+
</ul>
19+
<p>See <a href="https://nisei.net/players/supported-formats/">NISEI's Supported Formats page</a> for more information.</p>
20+
</div>
21+
<hr>
22+
<div id="restrictions-root" role="tabpanel" style="display: none;">
23+
<!-- Nav tabs -->
24+
<ul class="nav nav-pills nav-justified" role="tablist" style="margin-bottom:20px">
25+
<li role="presentation"><a href="#tab-pane-startup" role="tab" data-toggle="tab">Startup</a></li>
26+
<li role="presentation" class="active"><a href="#tab-pane-standard" role="tab" data-toggle="tab">Standard</a></li>
27+
<li role="presentation"><a href="#tab-pane-eternal" role="tab" data-toggle="tab">Eternal</a></li>
28+
</ul>
29+
<hr>
30+
<!-- Tab panes -->
31+
<div class="tab-content">
32+
<div role="tabpanel" class="tab-pane" id="tab-pane-startup"></div>
33+
<div role="tabpanel" class="tab-pane active" id="tab-pane-standard"></div>
34+
<div role="tabpanel" class="tab-pane" id="tab-pane-eternal"></div>
35+
</div>
36+
</div>
37+
38+
<noscript>
39+
<p class="text-center">Please enable JavaScript to view this page fully.</p>
40+
<hr>
41+
</noscript>
42+
</div>
43+
44+
<script>
45+
// Takes a list of cards and an object mapping integers to lists of card IDs
46+
// Returns a new object with the cards in place of their IDs
47+
function makeCardMap(cards, obj) {
48+
return Object.keys(obj).reduce(function(newObj, key) {
49+
newObj[key] = cards.filter(card => obj[key].includes(card.id));
50+
return newObj
51+
}, {});
52+
}
53+
54+
// Creates a list of cards with the given header
55+
function generateList(header, cards, pre='') {
56+
if (pre.length > 0) {
57+
pre = `<li>${pre}</li>`;
58+
}
59+
return cards.reduce((text, card) => {
60+
return text + `<li><a href="${cardToLatestPrintingLink(card)}">${card.attributes.title}</a></li>`;
61+
}, `<li><strong>${header}</strong><ul>${pre}`) + '</ul></li>';
62+
}
63+
64+
async function buildBanlistsView() {
65+
// Unhide the restrictions view (accounting for browsers with JS disabled)
66+
$('#restrictions-root').show();
67+
68+
// Add a temporary loading indicator
69+
$('.tab-pane').append('<p class="temp-loading text-center"><span class="loading-icon"><span class="loading-icon-red"></span><span class="loading-icon-blue"></span><span class="loading-icon-overlap"></span></span>Loading...</p>');
70+
71+
// Load data from API
72+
const desiredFormats = ['startup', 'standard', 'eternal'];
73+
const [formats, cards, restrictions] = await Promise.all([
74+
fetchData(`{{ v3_api_url }}/api/v3/public/formats`).then(fs => fs.filter(f => desiredFormats.includes(f.id))),
75+
fetchCards(`?include=card_subtypes&filter[search]=in_restriction:true`, 250).then(splitBySide),
76+
fetchData(`{{ v3_api_url }}/api/v3/public/restrictions?sort=-date_start`)
77+
]);
78+
79+
// Remove the loading indicator
80+
$('.temp-loading').remove();
81+
82+
// Add each format to the page
83+
// Start by adding an empty text box for each restriction
84+
formats.forEach(f => {
85+
$(`#tab-pane-${f.id}`).append(`<div class="list"><div class="row"><div id="${f.id}" class="col-sm-12"></div></div></div>`);
86+
const jqCol = $(`#${f.id}`);
87+
const formatRestrictions = restrictions.filter(r => f.attributes.restriction_ids.includes(r.id));
88+
if (formatRestrictions.length == 0) {
89+
jqCol.append(`<p>No cards are currently banned in ${f.attributes.name}. Have a blast!</p>`);
90+
} else {
91+
jqCol.append('<p><button class="show-all btn btn-secondary">Show all</button><button class="hide-all pull-right btn btn-secondary">Hide all</button></p>');
92+
formatRestrictions.forEach(r => {
93+
const active = r.id == f.attributes.active_restriction_id;
94+
const visible = active || r.id == formatRestrictions[0].id;
95+
// Create panel
96+
jqCol.append(`<div id="restriction-${r.id}" class="panel panel-default"></div>`);
97+
const jqPanel = $(`#restriction-${r.id}`);
98+
// Add header
99+
jqPanel.append(`<div class="panel-heading" style="display: flex;"></div>`);
100+
jqPanel.find(`.panel-heading`).append(`<h3 style="margin: 15px 0 10px 0">${r.attributes.name}${active ? " <em>(active)</em>" : ""}</h3>`)
101+
.append(`<button class="list-toggle btn btn-secondary" style="margin-left:auto; margin-top: auto; margin-bottom: auto;">${visible ? 'Hide' : 'Show'}</button>`);
102+
// Add subheader (search link hyphenated for legacy IDs)
103+
jqPanel.append(`<div class="panel-heading" ${visible ? '' : 'style="display: none;"'}><a href=${Routing.generate('cards_find', {type:'find', 'view':'list', 'q':`b!${r.id.replaceAll('_', '-')}`})}>${r.attributes.size} cards</a>. Start Date: ${r.attributes.date_start}.</div>`);
104+
});
105+
}
106+
});
107+
108+
// Set up event handling
109+
$('.list-toggle').on('click', function (event) {
110+
if ($(this).html() == 'Hide') {
111+
$(this).html('Show');
112+
$(this).closest('.panel').children(':not(:first-child)').hide(250);
113+
} else {
114+
$(this).html('Hide');
115+
$(this).closest('.panel').children().show(250);
116+
}
117+
});
118+
$('.show-all').on('click', function (event) {
119+
const panels = $(this).closest('.list');
120+
panels.find('.panel').children().show();
121+
panels.find('.list-toggle').html('Hide');
122+
});
123+
$('.hide-all').on('click', function (event) {
124+
const panels = $(this).closest('.list');
125+
panels.find('.panel').children(':not(:first-child)').hide();
126+
panels.find('.list-toggle').html('Show');
127+
});
128+
129+
// Add the restriction data to each text box
130+
restrictions.forEach(r => {
131+
const v = r.attributes.verdicts;
132+
133+
// Lists (bans, restricted cards, global penalty cards)
134+
const [corpBan, runnerBan] = cards.map(cs => cs.filter(card => v.banned?.includes(card.id)));
135+
const [corpRes, runnerRes] = cards.map(cs => cs.filter(card => v.restricted?.includes(card.id)));
136+
const [corpPen, runnerPen] = cards.map(cs => cs.filter(card => v.global_penalty?.includes(card.id)));
137+
138+
// Mappings (points, universal faction costs)
139+
const [corpUFC, runnerUFC] = cards.map(cs => makeCardMap(cs, v.universal_faction_cost));
140+
const [corpPts, runnerPts] = cards.map(cs => makeCardMap(cs, v.points));
141+
142+
// Get the panel DOM object
143+
const jqPanel = $(`#restriction-${r.id}`);
144+
145+
// Add body
146+
jqPanel.append(`<div class="panel-body" ${jqPanel.find(`button`).html() == 'Show' ? 'style="display: none;"' : ''}><div class="container-fluid"><div class="row flex-fill">`);
147+
148+
// Generate corp restrictions
149+
jqPanel.find(`.row`).append(`<div class="col-md-6"><h3>Corp Cards</h3><ul id="${r.id}-corp"></ul></div>`);
150+
const jqCorp = $(`#${r.id}-corp`);
151+
// Bans (banned subtypes (i.e. currents) are removed beforehand to reduce length)
152+
if (corpBan.length > 0) {
153+
if (r.attributes.banned_subtypes.length > 0) { // NOTE: currently hardcoded to only be currents
154+
const pre = `All cards with the <strong><a href="${Routing.generate('cards_find', {type:'find', 'view':'list', 'q':'s:current d:corp'})}">Current</a></strong> subtype.`;
155+
jqCorp.append(generateList('Banned', removeCurrents(corpBan), pre));
156+
} else {
157+
jqCorp.append(generateList('Banned', corpBan));
158+
}
159+
}
160+
// The others
161+
if (corpRes.length > 0) { jqCorp.append(generateList('Restricted', corpRes)); }
162+
Object.keys(corpUFC).sort().reverse().forEach(p => { jqCorp.append(generateList(`+${p} Universal Influence`, corpUFC[p])); });
163+
if (corpPen.length > 0) { jqCorp.append(generateList('Identity Influence Reduction', corpPen)); }
164+
Object.keys(corpPts).sort().reverse().forEach(p => { jqCorp.append(generateList(`${p} ${p == 1 ? 'Point' : 'Points'}`, corpPts[p])); });
165+
166+
// Generate runner restrictions
167+
jqPanel.find('.row').append(`<div class="col-md-6"><h3>Runner Cards</h3><ul id="${r.id}-runner"></ul></div>`);
168+
const jqRunner = $(`#${r.id}-runner`);
169+
// Bans (banned subtypes (i.e. currents) are removed beforehand to reduce length)
170+
if (runnerBan.length > 0) {
171+
if (r.attributes.banned_subtypes.length > 0) { // NOTE: currently hardcoded to only be currents
172+
const pre = `All cards with the <strong><a href="${Routing.generate('cards_find', {type:'find', 'view':'list', 'q':'s:current d:runner'})}">Current</a></strong> subtype.`;
173+
jqRunner.append(generateList('Banned', removeCurrents(runnerBan), pre));
174+
} else {
175+
jqRunner.append(generateList('Banned', runnerBan));
176+
}
177+
}
178+
// The others
179+
if (runnerRes.length > 0) { jqRunner.append(generateList('Restricted', runnerRes)); }
180+
Object.keys(runnerUFC).sort().reverse().forEach(p => { jqRunner.append(generateList(`+${p} Universal Influence`, runnerUFC[p])); });
181+
if (runnerPen.length > 0) { jqRunner.append(generateList('Identity Influence Reduction', runnerPen)); }
182+
Object.keys(runnerPts).sort().reverse().forEach(p => { jqRunner.append(generateList(`${p} ${p == 1 ? 'Point' : 'Points'}`, runnerPts[p])); });
183+
});
184+
}
185+
186+
// Create the banlists view on load
187+
buildBanlistsView();
188+
</script>
189+
190+
{% endblock %}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<script type="text/javascript">
2+
3+
// Fetches the json from an api url
4+
async function fetchJson(url) {
5+
return await fetch(url).then(data => data.json());
6+
}
7+
8+
// Fetches the data field from a url (not accounting for pagination)
9+
async function fetchData(url) {
10+
return await fetchJson(url).then(json => json.data);
11+
}
12+
13+
// Fetches all cards from the v2 API (accounting for pagination)
14+
// Returns a list of cards
15+
async function fetchCards(flags='', pageLimit) {
16+
const data = [];
17+
pageLimit = (pageLimit != undefined) ? `&page[limit]=${pageLimit}` : '';
18+
let json = await fetch(`${v3_api_url}/api/v3/public/cards/${flags}${pageLimit}`).then(data => data.json());
19+
data.push(...json.data);
20+
while ('next' in json.links) {
21+
json = await fetch(json.links.next).then(data => data.json());
22+
data.push(...json.data);
23+
}
24+
return data;
25+
}
26+
27+
// Removes currents from a list of cards
28+
// Assumes the cards have their list of subtypes exposed
29+
function removeCurrents(cards) {
30+
return cards.filter(c => !c.relationships.card_subtypes.data.map(d => d.id).includes('current'));
31+
}
32+
33+
// Turns a json object containing a list of objects with an ID into a map from IDs to those objects
34+
function makeIdMap(json) {
35+
const out = new Map();
36+
json.data.forEach(d => {
37+
out.set(d.id, d);
38+
});
39+
return out;
40+
}
41+
42+
// Filters a list of cards for the one with the given ID
43+
function getCardsById(cards, ids) {
44+
return cards.filter(card => ids.includes(card.id));
45+
}
46+
47+
// Maps a _=>card_id object to a _=>card object
48+
function getCardsByIdFromObj(cards, obj) {
49+
return Object.keys(obj).reduce(function(newObj, key) {
50+
newObj[key] = getCardsById(cards, obj[key])
51+
return newObj
52+
}, {});
53+
}
54+
55+
// Splits a list of cards into corp cards and runner cards
56+
function splitBySide(cards) {
57+
const corp = [];
58+
const runner = [];
59+
cards.forEach (card => {
60+
if (card.attributes.side_id == 'corp')
61+
corp.push(card);
62+
else
63+
runner.push(card);
64+
});
65+
return [corp, runner];
66+
}
67+
68+
// Generates a link to a card by creating a link to its most recent printing
69+
function cardToLatestPrintingLink(card) {
70+
return Routing.generate('cards_zoom', {card_code:card.attributes.latest_printing_id});
71+
}
72+
73+
</script>

app/Resources/views/layout.html.twig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
locale: '{{ app.request.locale }}'
7676
};
7777
moment.locale(window.navigator.language);
78+
var v3_api_url = '{{ v3_api_url }}';
7879
</script>
7980
{% if app.environment == 'prod' %}
8081
<!-- Global site tag (gtag.js) - Google Analytics -->

0 commit comments

Comments
 (0)