Rudimentary game-suggestion

This commit is contained in:
Jack Jackson 2024-08-03 12:23:42 -07:00
parent 5d2183bbf0
commit 8b5d96e76f
7 changed files with 225 additions and 2 deletions

View File

@ -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

View File

@ -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
View 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},
)

View 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)
})

View File

@ -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>

View 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 %}

View File

@ -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