Rudimentary game-suggestion
This commit is contained in:
parent
5d2183bbf0
commit
8b5d96e76f
@ -66,4 +66,4 @@ RUN apt update
|
|||||||
RUN apt install sqlite3
|
RUN apt install sqlite3
|
||||||
USER appuser
|
USER appuser
|
||||||
# Expects that the source code will be mounted into the container
|
# 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 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")
|
api_router = APIRouter(prefix="/api")
|
||||||
html_router = APIRouter()
|
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(score.api_router)
|
||||||
api_router.include_router(seed.api_router)
|
api_router.include_router(seed.api_router)
|
||||||
api_router.include_router(stats.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(decks.html_router)
|
||||||
html_router.include_router(players.html_router)
|
html_router.include_router(players.html_router)
|
||||||
html_router.include_router(games.html_router)
|
html_router.include_router(games.html_router)
|
||||||
html_router.include_router(seed.html_router)
|
html_router.include_router(seed.html_router)
|
||||||
html_router.include_router(stats.html_router)
|
html_router.include_router(stats.html_router)
|
||||||
|
html_router.include_router(suggest.html_router)
|
||||||
|
|
||||||
html_router.include_router(base.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<a class="topbar_item" href="/suggest/matchup">Suggest Matchup</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div id="create_game_button">
|
<div id="create_game_button">
|
||||||
<a id="create_game_link" href="/game/create">Record New Game</a>
|
<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/
|
source: ./app/
|
||||||
# Yes, really - we're using `/app` as the WD within the container, but `uvicorn` requires an import path.
|
# Yes, really - we're using `/app` as the WD within the container, but `uvicorn` requires an import path.
|
||||||
target: /app/app
|
target: /app/app
|
||||||
|
- ./local-run-log-config.yaml:/app/local-run-log-config.yaml:delegated
|
||||||
ports:
|
ports:
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user