Rudimentary game-suggestion
This commit is contained in:
parent
5d2183bbf0
commit
8b5d96e76f
@ -66,4 +66,4 @@ RUN apt update
|
||||
RUN apt install sqlite3
|
||||
USER appuser
|
||||
# Expects that the source code will be mounted into the container
|
||||
CMD uvicorn app:app --reload --host 0.0.0.0
|
||||
CMD uvicorn app:app --reload --host 0.0.0.0 --log-config ./local-run-log-config.yaml
|
||||
|
@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import base, decks, games, players, score, seed, stats
|
||||
from . import base, decks, games, players, score, seed, stats, suggest
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
html_router = APIRouter()
|
||||
@ -11,11 +11,13 @@ api_router.include_router(games.api_router)
|
||||
api_router.include_router(score.api_router)
|
||||
api_router.include_router(seed.api_router)
|
||||
api_router.include_router(stats.api_router)
|
||||
api_router.include_router(suggest.api_router)
|
||||
|
||||
html_router.include_router(decks.html_router)
|
||||
html_router.include_router(players.html_router)
|
||||
html_router.include_router(games.html_router)
|
||||
html_router.include_router(seed.html_router)
|
||||
html_router.include_router(stats.html_router)
|
||||
html_router.include_router(suggest.html_router)
|
||||
|
||||
html_router.include_router(base.html_router)
|
||||
|
145
app/routers/suggest.py
Normal file
145
app/routers/suggest.py
Normal file
@ -0,0 +1,145 @@
|
||||
import logging
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from itertools import product
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.expression import func
|
||||
from typing import Callable, List, Mapping, Optional
|
||||
|
||||
from app.routers import players
|
||||
from app.sql import models
|
||||
from ..sql.database import get_db
|
||||
from ..templates import jinja_templates
|
||||
|
||||
api_router = APIRouter(prefix="/suggest", tags=["suggest"])
|
||||
html_router = APIRouter(
|
||||
prefix="/suggest", include_in_schema=False, default_response_class=HTMLResponse
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@api_router.get("/matchup")
|
||||
def suggest_game(
|
||||
player_ids: Optional[str] = None,
|
||||
excluded_decks: Optional[str] = None,
|
||||
strategy_id: Optional[str] = None,
|
||||
db=Depends(get_db),
|
||||
):
|
||||
if not strategy_id:
|
||||
strategy_id = "smallest_score_spread"
|
||||
if strategy_id not in STRATEGIES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot generate a matchup recommendation with strategy {strategy_id}. Available options: {STRATEGIES.keys()}",
|
||||
)
|
||||
|
||||
# TODO - this should be an error, surely!
|
||||
player_ids_as_list = list(map(int, player_ids.split(","))) if player_ids else []
|
||||
excluded_decks_as_list = (
|
||||
list(map(int, excluded_decks.split(","))) if excluded_decks else []
|
||||
)
|
||||
LOGGER.info(f"{player_ids=}")
|
||||
LOGGER.info(f"{excluded_decks=}")
|
||||
return STRATEGIES[strategy_id].strategy(
|
||||
player_ids_as_list, excluded_decks_as_list, db
|
||||
)
|
||||
|
||||
|
||||
def smallest_score_spread_strategy(
|
||||
player_ids: List[int], excluded_decks: List[int], db: Session
|
||||
):
|
||||
row_number_column = (
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=[models.Deck.name],
|
||||
order_by=models.EloScore.id.desc(),
|
||||
)
|
||||
.label("row_number")
|
||||
)
|
||||
|
||||
sub_query = (
|
||||
db.query(
|
||||
models.Deck.id, models.Deck.name, models.Player.name, models.EloScore.score
|
||||
)
|
||||
.join(models.Player, models.Deck.owner_id == models.Player.id)
|
||||
.join(models.EloScore, models.Deck.id == models.EloScore.deck_id)
|
||||
.filter(models.Deck.owner_id.in_(player_ids))
|
||||
.filter(models.Deck.id.not_in(excluded_decks))
|
||||
.add_column(row_number_column)
|
||||
)
|
||||
|
||||
sub_query = sub_query.subquery()
|
||||
query = db.query(sub_query).filter(sub_query.c.row_number == 1)
|
||||
results = query.all()
|
||||
|
||||
decks_by_player = defaultdict(list)
|
||||
for row in results:
|
||||
LOGGER.info(f"Processing {row}")
|
||||
decks_by_player[row[2]].append(
|
||||
{"id": row[0], "name": row[1], "latest_score": row[3]}
|
||||
)
|
||||
|
||||
# Tried using `heapq` for this logic but it keps producing duplicates :shrug:
|
||||
matchups = []
|
||||
for info_tuple in product(*decks_by_player.values()):
|
||||
scores = [info["latest_score"] for info in info_tuple]
|
||||
score_spread = max([abs(x - y) for (x, y) in product(scores, repeat=2)])
|
||||
matchup = Matchup(
|
||||
# Oof, there's _definitely_ a better way of doing this...
|
||||
participants=list(
|
||||
zip(decks_by_player.keys(), [info["name"] for info in info_tuple])
|
||||
),
|
||||
score=100 - score_spread,
|
||||
description=f"Score spread of {score_spread}, between {max(scores)} and {min(scores)}",
|
||||
)
|
||||
matchups.append((score_spread, matchup))
|
||||
|
||||
matchups.sort(key=lambda tup: tup[0])
|
||||
return [item[1] for item in matchups[:5]]
|
||||
|
||||
|
||||
# `order=True` so that tuples containing these can be put into a heap
|
||||
@dataclass(order=True)
|
||||
class Matchup:
|
||||
# `participants` is a List of dictionaries containing `player_name` and `deck_name`
|
||||
participants: List[Mapping]
|
||||
score: int
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyDefinition:
|
||||
strategy_id: str
|
||||
friendly_name: str
|
||||
# Parameters are:
|
||||
# `List[int]` of player_ids
|
||||
# `List[int]` of excluded_decks
|
||||
# `Session`, a db-connection
|
||||
strategy: Callable[[List[int], List[int], Session], List[Matchup]]
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
STRATEGIES: Mapping[str, StrategyDefinition] = {
|
||||
strat_def.strategy_id: strat_def
|
||||
for strat_def in [
|
||||
StrategyDefinition(
|
||||
strategy_id="smallest_score_spread",
|
||||
friendly_name="Smallest Score Spread",
|
||||
strategy=smallest_score_spread_strategy,
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@html_router.get("/matchup")
|
||||
def suggest_matchup_html(request: Request, db=Depends(get_db)):
|
||||
return jinja_templates.TemplateResponse(
|
||||
request,
|
||||
"suggest/matchup.html",
|
||||
{"players": players.list_players(db=db), "strategies": STRATEGIES},
|
||||
)
|
43
app/static/js/suggest/matchup.js
Normal file
43
app/static/js/suggest/matchup.js
Normal file
@ -0,0 +1,43 @@
|
||||
function change_strategy(eventData) {
|
||||
$('#strategy_select option[value=-1]').attr('disabled', 'disabled')
|
||||
}
|
||||
|
||||
function get_matchup(eventData) {
|
||||
console.log($('.player-checkbox:checked'))
|
||||
player_ids = $('.player-checkbox:checked').map(function(){return $(this).val()}).get().join()
|
||||
strategy_id = $('#strategy_select').val()
|
||||
console.log(`Player_ids is ${player_ids}`)
|
||||
console.log(`Strategy_id is ${strategy_id}`)
|
||||
|
||||
$.ajax({
|
||||
type: 'GET',
|
||||
url: '/api/suggest/matchup?player_id',
|
||||
data: {
|
||||
player_ids: player_ids,
|
||||
strategy_id: strategy_id
|
||||
},
|
||||
contentType: 'application/json',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
console.log(data);
|
||||
$('#matchup_display_div').html('<h1>Suggested matchups</h1>')
|
||||
for (var i = 0; i<data.length; i++) {
|
||||
matchup = data[i]
|
||||
$('#matchup_display_div').append($(`<h2>Matchup ${i+1}</h2>`))
|
||||
console.log(`${matchup['description']}`)
|
||||
$('#matchup_display_div').append($(`<strong>Description:</strong> <span>${matchup['description']}</span>`))
|
||||
$('#matchup_display_div').append($(`<h3>Participants</h3>`))
|
||||
ol = $('#matchup_display_div').append($('<ol>'))
|
||||
for (var j = 0; j<matchup.participants.length; j++) {
|
||||
ol.append($(`<li><strong>${matchup.participants[j][0]}</strong> playing <strong>${matchup.participants[j][1]}</strong></li>`))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
$('#strategy_select').on('change', change_strategy);
|
||||
|
||||
$('#get_matchup').click(get_matchup)
|
||||
})
|
@ -26,6 +26,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="topbar_item" href="/suggest/matchup">Suggest Matchup</a>
|
||||
|
||||
</div>
|
||||
<div id="create_game_button">
|
||||
<a id="create_game_link" href="/game/create">Record New Game</a>
|
||||
|
30
app/templates/suggest/matchup.html
Normal file
30
app/templates/suggest/matchup.html
Normal file
@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Suggest Matchup{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<script src="/static/js/suggest/matchup.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Suggest a Matchup</h1>
|
||||
<h2>Who's playing?</h2>
|
||||
{% for player in players %}
|
||||
<input type="checkbox" id="checkbox-{{ player.id }}" name="checkbox-{{ player.id }}" class="player-checkbox" value="{{ player.id }}">
|
||||
<label for="checkbox-{{ player.id }}">{{ player.name }}</label>
|
||||
{% endfor %}
|
||||
|
||||
<h2>How do you want to pick a matchup?</h2>
|
||||
<select name="strategy_select" id="strategy_select">
|
||||
<option value="-1">Select Strategy...</option>
|
||||
{% for strategy in strategies.values() %}
|
||||
<option value="{{ strategy.strategy_id }}">{{ strategy.friendly_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<br/>
|
||||
<br/>
|
||||
<button id="get_matchup">Submit</button>
|
||||
<div id="matchup_display_div"></div>
|
||||
|
||||
{% endblock %}
|
@ -9,6 +9,7 @@ services:
|
||||
source: ./app/
|
||||
# Yes, really - we're using `/app` as the WD within the container, but `uvicorn` requires an import path.
|
||||
target: /app/app
|
||||
- ./local-run-log-config.yaml:/app/local-run-log-config.yaml:delegated
|
||||
ports:
|
||||
- 8000:8000
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user