Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc.
This commit is contained in:
parent
d9073f862c
commit
f80bab3b4b
86
.env.example
Normal file
86
.env.example
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Golf Game Server Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and customize as needed.
|
||||||
|
# All values shown are defaults.
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Server Settings
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Host to bind to (0.0.0.0 for all interfaces)
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Port to listen on
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# Enable debug mode (more verbose logging, auto-reload)
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Database
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# SQLite database for game logs and stats
|
||||||
|
# For PostgreSQL: postgresql://user:pass@host:5432/dbname
|
||||||
|
DATABASE_URL=sqlite:///games.db
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Room Settings
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Maximum players per game room
|
||||||
|
MAX_PLAYERS_PER_ROOM=6
|
||||||
|
|
||||||
|
# Room timeout in minutes (inactive rooms are cleaned up)
|
||||||
|
ROOM_TIMEOUT_MINUTES=60
|
||||||
|
|
||||||
|
# Length of room codes (e.g., 4 = "ABCD")
|
||||||
|
ROOM_CODE_LENGTH=4
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Security & Authentication (for future auth system)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Secret key for JWT tokens (generate with: python -c "import secrets; print(secrets.token_hex(32))")
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
# Enable invite-only mode (requires invitation to register)
|
||||||
|
INVITE_ONLY=false
|
||||||
|
|
||||||
|
# Comma-separated list of admin email addresses
|
||||||
|
ADMIN_EMAILS=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Game Defaults
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Default number of rounds (holes) per game
|
||||||
|
DEFAULT_ROUNDS=9
|
||||||
|
|
||||||
|
# Cards to flip at start of each round (0, 1, or 2)
|
||||||
|
DEFAULT_INITIAL_FLIPS=2
|
||||||
|
|
||||||
|
# Enable jokers in deck by default
|
||||||
|
DEFAULT_USE_JOKERS=false
|
||||||
|
|
||||||
|
# Require flipping a card after discarding from deck
|
||||||
|
DEFAULT_FLIP_ON_DISCARD=false
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Card Values (Standard 6-Card Golf)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Customize point values for cards. Normally you shouldn't change these.
|
||||||
|
|
||||||
|
CARD_ACE=1
|
||||||
|
CARD_TWO=-2
|
||||||
|
CARD_KING=0
|
||||||
|
CARD_JOKER=-2
|
||||||
|
|
||||||
|
# House rule values
|
||||||
|
CARD_SUPER_KINGS=-2 # King value when super_kings enabled
|
||||||
|
CARD_TEN_PENNY=1 # 10 value when ten_penny enabled
|
||||||
|
CARD_LUCKY_SWING_JOKER=-5 # Joker value when lucky_swing enabled
|
||||||
247
bin/Activate.ps1
Normal file
247
bin/Activate.ps1
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Activate a Python virtual environment for the current PowerShell session.
|
||||||
|
|
||||||
|
.Description
|
||||||
|
Pushes the python executable for a virtual environment to the front of the
|
||||||
|
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||||
|
in a Python virtual environment. Makes use of the command line switches as
|
||||||
|
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||||
|
|
||||||
|
.Parameter VenvDir
|
||||||
|
Path to the directory that contains the virtual environment to activate. The
|
||||||
|
default value for this is the parent of the directory that the Activate.ps1
|
||||||
|
script is located within.
|
||||||
|
|
||||||
|
.Parameter Prompt
|
||||||
|
The prompt prefix to display when this virtual environment is activated. By
|
||||||
|
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||||
|
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Verbose
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and shows extra information about the activation as it executes.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||||
|
Activates the Python virtual environment located in the specified location.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Prompt "MyPython"
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and prefixes the current prompt with the specified string (surrounded in
|
||||||
|
parentheses) while the virtual environment is active.
|
||||||
|
|
||||||
|
.Notes
|
||||||
|
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||||
|
execution policy for the user. You can do this by issuing the following PowerShell
|
||||||
|
command:
|
||||||
|
|
||||||
|
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
For more information on Execution Policies:
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||||
|
|
||||||
|
#>
|
||||||
|
Param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$VenvDir,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$Prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
<# Function declarations --------------------------------------------------- #>
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Remove all shell session elements added by the Activate script, including the
|
||||||
|
addition of the virtual environment's Python executable from the beginning of
|
||||||
|
the PATH variable.
|
||||||
|
|
||||||
|
.Parameter NonDestructive
|
||||||
|
If present, do not remove this function from the global namespace for the
|
||||||
|
session.
|
||||||
|
|
||||||
|
#>
|
||||||
|
function global:deactivate ([switch]$NonDestructive) {
|
||||||
|
# Revert to original values
|
||||||
|
|
||||||
|
# The prior prompt:
|
||||||
|
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||||
|
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||||
|
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PYTHONHOME:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PATH:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the VIRTUAL_ENV altogether:
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||||
|
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||||
|
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Leave deactivate function in the global namespace if requested:
|
||||||
|
if (-not $NonDestructive) {
|
||||||
|
Remove-Item -Path function:deactivate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Description
|
||||||
|
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||||
|
given folder, and returns them in a map.
|
||||||
|
|
||||||
|
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||||
|
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||||
|
then it is considered a `key = value` line. The left hand string is the key,
|
||||||
|
the right hand is the value.
|
||||||
|
|
||||||
|
If the value starts with a `'` or a `"` then the first and last character is
|
||||||
|
stripped from the value before being captured.
|
||||||
|
|
||||||
|
.Parameter ConfigDir
|
||||||
|
Path to the directory that contains the `pyvenv.cfg` file.
|
||||||
|
#>
|
||||||
|
function Get-PyVenvConfig(
|
||||||
|
[String]
|
||||||
|
$ConfigDir
|
||||||
|
) {
|
||||||
|
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||||
|
|
||||||
|
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||||
|
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||||
|
|
||||||
|
# An empty map will be returned if no config file is found.
|
||||||
|
$pyvenvConfig = @{ }
|
||||||
|
|
||||||
|
if ($pyvenvConfigPath) {
|
||||||
|
|
||||||
|
Write-Verbose "File exists, parse `key = value` lines"
|
||||||
|
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||||
|
|
||||||
|
$pyvenvConfigContent | ForEach-Object {
|
||||||
|
$keyval = $PSItem -split "\s*=\s*", 2
|
||||||
|
if ($keyval[0] -and $keyval[1]) {
|
||||||
|
$val = $keyval[1]
|
||||||
|
|
||||||
|
# Remove extraneous quotations around a string value.
|
||||||
|
if ("'""".Contains($val.Substring(0, 1))) {
|
||||||
|
$val = $val.Substring(1, $val.Length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyvenvConfig[$keyval[0]] = $val
|
||||||
|
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $pyvenvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<# Begin Activate script --------------------------------------------------- #>
|
||||||
|
|
||||||
|
# Determine the containing directory of this script
|
||||||
|
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||||
|
|
||||||
|
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||||
|
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||||
|
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||||
|
|
||||||
|
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||||
|
# First, get the location of the virtual environment, it might not be
|
||||||
|
# VenvExecDir if specified on the command line.
|
||||||
|
if ($VenvDir) {
|
||||||
|
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||||
|
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||||
|
Write-Verbose "VenvDir=$VenvDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||||
|
# as `prompt`.
|
||||||
|
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||||
|
|
||||||
|
# Next, set the prompt from the command line, or the config file, or
|
||||||
|
# just use the name of the virtual environment folder.
|
||||||
|
if ($Prompt) {
|
||||||
|
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||||
|
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||||
|
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||||
|
$Prompt = $pyvenvCfg['prompt'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||||
|
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||||
|
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Prompt = '$Prompt'"
|
||||||
|
Write-Verbose "VenvDir='$VenvDir'"
|
||||||
|
|
||||||
|
# Deactivate any currently active virtual environment, but leave the
|
||||||
|
# deactivate function in place.
|
||||||
|
deactivate -nondestructive
|
||||||
|
|
||||||
|
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||||
|
# that there is an activated venv.
|
||||||
|
$env:VIRTUAL_ENV = $VenvDir
|
||||||
|
|
||||||
|
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||||
|
|
||||||
|
Write-Verbose "Setting prompt to '$Prompt'"
|
||||||
|
|
||||||
|
# Set the prompt to include the env name
|
||||||
|
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||||
|
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||||
|
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||||
|
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||||
|
|
||||||
|
function global:prompt {
|
||||||
|
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||||
|
_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear PYTHONHOME
|
||||||
|
if (Test-Path -Path Env:PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
Remove-Item -Path Env:PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the venv to the PATH
|
||||||
|
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||||
|
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||||
76
bin/activate
Normal file
76
bin/activate
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# This file must be used with "source bin/activate" *from bash*
|
||||||
|
# You cannot run it directly
|
||||||
|
|
||||||
|
deactivate () {
|
||||||
|
# reset old environment variables
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||||
|
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||||
|
export PATH
|
||||||
|
unset _OLD_VIRTUAL_PATH
|
||||||
|
fi
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||||
|
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||||
|
export PYTHONHOME
|
||||||
|
unset _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||||
|
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||||
|
export PS1
|
||||||
|
unset _OLD_VIRTUAL_PS1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset VIRTUAL_ENV
|
||||||
|
unset VIRTUAL_ENV_PROMPT
|
||||||
|
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||||
|
# Self destruct!
|
||||||
|
unset -f deactivate
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# unset irrelevant variables
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||||
|
if [ "$OSTYPE" = "cygwin" ] || [ "$OSTYPE" = "msys" ] ; then
|
||||||
|
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||||
|
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||||
|
export VIRTUAL_ENV=$(cygpath "/home/alee/Sources/golfgame")
|
||||||
|
else
|
||||||
|
# use the path as-is
|
||||||
|
export VIRTUAL_ENV="/home/alee/Sources/golfgame"
|
||||||
|
fi
|
||||||
|
|
||||||
|
_OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
# unset PYTHONHOME if set
|
||||||
|
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||||
|
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||||
|
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||||
|
unset PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||||
|
PS1="(golfgame) ${PS1:-}"
|
||||||
|
export PS1
|
||||||
|
VIRTUAL_ENV_PROMPT="(golfgame) "
|
||||||
|
export VIRTUAL_ENV_PROMPT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
27
bin/activate.csh
Normal file
27
bin/activate.csh
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||||
|
# You cannot run it directly.
|
||||||
|
|
||||||
|
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||||
|
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||||
|
|
||||||
|
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
setenv VIRTUAL_ENV "/home/alee/Sources/golfgame"
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||||
|
|
||||||
|
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||||
|
set prompt = "(golfgame) $prompt"
|
||||||
|
setenv VIRTUAL_ENV_PROMPT "(golfgame) "
|
||||||
|
endif
|
||||||
|
|
||||||
|
alias pydoc python -m pydoc
|
||||||
|
|
||||||
|
rehash
|
||||||
69
bin/activate.fish
Normal file
69
bin/activate.fish
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||||
|
# (https://fishshell.com/). You cannot run it directly.
|
||||||
|
|
||||||
|
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||||
|
# reset old environment variables
|
||||||
|
if test -n "$_OLD_VIRTUAL_PATH"
|
||||||
|
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||||
|
set -e _OLD_VIRTUAL_PATH
|
||||||
|
end
|
||||||
|
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||||
|
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||||
|
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||||
|
# prevents error when using nested fish instances (Issue #93858)
|
||||||
|
if functions -q _old_fish_prompt
|
||||||
|
functions -e fish_prompt
|
||||||
|
functions -c _old_fish_prompt fish_prompt
|
||||||
|
functions -e _old_fish_prompt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
set -e VIRTUAL_ENV
|
||||||
|
set -e VIRTUAL_ENV_PROMPT
|
||||||
|
if test "$argv[1]" != "nondestructive"
|
||||||
|
# Self-destruct!
|
||||||
|
functions -e deactivate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
set -gx VIRTUAL_ENV "/home/alee/Sources/golfgame"
|
||||||
|
|
||||||
|
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||||
|
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
|
||||||
|
|
||||||
|
# Unset PYTHONHOME if set.
|
||||||
|
if set -q PYTHONHOME
|
||||||
|
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||||
|
set -e PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||||
|
# fish uses a function instead of an env var to generate the prompt.
|
||||||
|
|
||||||
|
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||||
|
functions -c fish_prompt _old_fish_prompt
|
||||||
|
|
||||||
|
# With the original prompt function renamed, we can override with our own.
|
||||||
|
function fish_prompt
|
||||||
|
# Save the return status of the last command.
|
||||||
|
set -l old_status $status
|
||||||
|
|
||||||
|
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||||
|
printf "%s%s%s" (set_color 4B8BBE) "(golfgame) " (set_color normal)
|
||||||
|
|
||||||
|
# Restore the return status of the previous command.
|
||||||
|
echo "exit $old_status" | .
|
||||||
|
# Output the original/"old" prompt.
|
||||||
|
_old_fish_prompt
|
||||||
|
end
|
||||||
|
|
||||||
|
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||||
|
set -gx VIRTUAL_ENV_PROMPT "(golfgame) "
|
||||||
|
end
|
||||||
8
bin/pip
Executable file
8
bin/pip
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/alee/Sources/golfgame/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
bin/pip3
Executable file
8
bin/pip3
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/alee/Sources/golfgame/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
bin/pip3.12
Executable file
8
bin/pip3.12
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/alee/Sources/golfgame/bin/python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
1
bin/python
Symbolic link
1
bin/python
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/home/alee/.pyenv/versions/3.12.0/bin/python
|
||||||
1
bin/python3
Symbolic link
1
bin/python3
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
python
|
||||||
1
bin/python3.12
Symbolic link
1
bin/python3.12
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
python
|
||||||
378
client/animation-queue.js
Normal file
378
client/animation-queue.js
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
// AnimationQueue - Sequences card animations properly
|
||||||
|
// Ensures animations play in order without overlap
|
||||||
|
|
||||||
|
class AnimationQueue {
|
||||||
|
constructor(cardManager, getSlotRect, getLocationRect, playSound) {
|
||||||
|
this.cardManager = cardManager;
|
||||||
|
this.getSlotRect = getSlotRect; // Function to get slot position
|
||||||
|
this.getLocationRect = getLocationRect; // Function to get deck/discard position
|
||||||
|
this.playSound = playSound || (() => {}); // Sound callback
|
||||||
|
this.queue = [];
|
||||||
|
this.processing = false;
|
||||||
|
this.animationInProgress = false;
|
||||||
|
|
||||||
|
// Timing configuration (ms)
|
||||||
|
this.timing = {
|
||||||
|
flipDuration: 400,
|
||||||
|
moveDuration: 300,
|
||||||
|
pauseAfterMove: 200,
|
||||||
|
pauseAfterFlip: 100,
|
||||||
|
pauseBetweenAnimations: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add movements to the queue and start processing
|
||||||
|
async enqueue(movements, onComplete) {
|
||||||
|
if (!movements || movements.length === 0) {
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add completion callback to last movement
|
||||||
|
const movementsWithCallback = movements.map((m, i) => ({
|
||||||
|
...m,
|
||||||
|
onComplete: i === movements.length - 1 ? onComplete : null
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.queue.push(...movementsWithCallback);
|
||||||
|
|
||||||
|
if (!this.processing) {
|
||||||
|
await this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process queued animations one at a time
|
||||||
|
async processQueue() {
|
||||||
|
if (this.processing) return;
|
||||||
|
this.processing = true;
|
||||||
|
this.animationInProgress = true;
|
||||||
|
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const movement = this.queue.shift();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.animate(movement);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Animation error:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback after last movement
|
||||||
|
if (movement.onComplete) {
|
||||||
|
movement.onComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause between animations
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
await this.delay(this.timing.pauseBetweenAnimations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = false;
|
||||||
|
this.animationInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to appropriate animation
|
||||||
|
async animate(movement) {
|
||||||
|
switch (movement.type) {
|
||||||
|
case 'flip':
|
||||||
|
await this.animateFlip(movement);
|
||||||
|
break;
|
||||||
|
case 'swap':
|
||||||
|
await this.animateSwap(movement);
|
||||||
|
break;
|
||||||
|
case 'discard':
|
||||||
|
await this.animateDiscard(movement);
|
||||||
|
break;
|
||||||
|
case 'draw-deck':
|
||||||
|
await this.animateDrawDeck(movement);
|
||||||
|
break;
|
||||||
|
case 'draw-discard':
|
||||||
|
await this.animateDrawDiscard(movement);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate a card flip
|
||||||
|
async animateFlip(movement) {
|
||||||
|
const { playerId, position, faceUp, card } = movement;
|
||||||
|
|
||||||
|
// Get slot position
|
||||||
|
const slotRect = this.getSlotRect(playerId, position);
|
||||||
|
if (!slotRect || slotRect.width === 0 || slotRect.height === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create animation card at slot position
|
||||||
|
const animCard = this.createAnimCard();
|
||||||
|
this.cardManager.cardLayer.appendChild(animCard);
|
||||||
|
this.setCardPosition(animCard, slotRect);
|
||||||
|
|
||||||
|
const inner = animCard.querySelector('.card-inner');
|
||||||
|
const front = animCard.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Set up what we're flipping to (front face)
|
||||||
|
this.setCardFront(front, card);
|
||||||
|
|
||||||
|
// Start face down (flipped = showing back)
|
||||||
|
inner.classList.add('flipped');
|
||||||
|
|
||||||
|
// Force a reflow to ensure the initial state is applied
|
||||||
|
animCard.offsetHeight;
|
||||||
|
|
||||||
|
// Animate the flip
|
||||||
|
this.playSound('flip');
|
||||||
|
await this.delay(50); // Brief pause before flip
|
||||||
|
|
||||||
|
// Remove flipped to trigger animation to front
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
|
||||||
|
await this.delay(this.timing.flipDuration);
|
||||||
|
await this.delay(this.timing.pauseAfterFlip);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
animCard.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate a card swap (hand card to discard, drawn card to hand)
|
||||||
|
async animateSwap(movement) {
|
||||||
|
const { playerId, position, oldCard, newCard } = movement;
|
||||||
|
|
||||||
|
// Get positions
|
||||||
|
const slotRect = this.getSlotRect(playerId, position);
|
||||||
|
const discardRect = this.getLocationRect('discard');
|
||||||
|
const holdingRect = this.getLocationRect('holding');
|
||||||
|
|
||||||
|
if (!slotRect || !discardRect || slotRect.width === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary card element for the animation
|
||||||
|
const animCard = this.createAnimCard();
|
||||||
|
this.cardManager.cardLayer.appendChild(animCard);
|
||||||
|
|
||||||
|
// Position at slot
|
||||||
|
this.setCardPosition(animCard, slotRect);
|
||||||
|
|
||||||
|
// Start face down (showing back)
|
||||||
|
const inner = animCard.querySelector('.card-inner');
|
||||||
|
const front = animCard.querySelector('.card-face-front');
|
||||||
|
inner.classList.add('flipped');
|
||||||
|
|
||||||
|
// Step 1: If card was face down, flip to reveal it
|
||||||
|
if (!oldCard.face_up) {
|
||||||
|
// Set up the front with the old card content (what we're discarding)
|
||||||
|
this.setCardFront(front, oldCard);
|
||||||
|
|
||||||
|
this.playSound('flip');
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
await this.delay(this.timing.flipDuration);
|
||||||
|
} else {
|
||||||
|
// Already face up, just show it
|
||||||
|
this.setCardFront(front, oldCard);
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delay(100);
|
||||||
|
|
||||||
|
// Step 2: Move card to discard pile
|
||||||
|
this.playSound('card');
|
||||||
|
animCard.classList.add('moving');
|
||||||
|
this.setCardPosition(animCard, discardRect);
|
||||||
|
await this.delay(this.timing.moveDuration);
|
||||||
|
animCard.classList.remove('moving');
|
||||||
|
|
||||||
|
// Pause to show the card landing on discard
|
||||||
|
await this.delay(this.timing.pauseAfterMove + 200);
|
||||||
|
|
||||||
|
// Step 3: Create second card for the new card coming into hand
|
||||||
|
const newAnimCard = this.createAnimCard();
|
||||||
|
this.cardManager.cardLayer.appendChild(newAnimCard);
|
||||||
|
|
||||||
|
// New card starts at holding/discard position
|
||||||
|
this.setCardPosition(newAnimCard, holdingRect || discardRect);
|
||||||
|
const newInner = newAnimCard.querySelector('.card-inner');
|
||||||
|
const newFront = newAnimCard.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Show new card (it's face up from the drawn card)
|
||||||
|
this.setCardFront(newFront, newCard);
|
||||||
|
newInner.classList.remove('flipped');
|
||||||
|
|
||||||
|
// Step 4: Move new card to the hand slot
|
||||||
|
this.playSound('card');
|
||||||
|
newAnimCard.classList.add('moving');
|
||||||
|
this.setCardPosition(newAnimCard, slotRect);
|
||||||
|
await this.delay(this.timing.moveDuration);
|
||||||
|
newAnimCard.classList.remove('moving');
|
||||||
|
|
||||||
|
// Clean up animation cards
|
||||||
|
await this.delay(this.timing.pauseAfterMove);
|
||||||
|
animCard.remove();
|
||||||
|
newAnimCard.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary animation card element
|
||||||
|
createAnimCard() {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'real-card anim-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-inner">
|
||||||
|
<div class="card-face card-face-front"></div>
|
||||||
|
<div class="card-face card-face-back"><span>?</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set card position
|
||||||
|
setCardPosition(card, rect) {
|
||||||
|
card.style.left = `${rect.left}px`;
|
||||||
|
card.style.top = `${rect.top}px`;
|
||||||
|
card.style.width = `${rect.width}px`;
|
||||||
|
card.style.height = `${rect.height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set card front content
|
||||||
|
setCardFront(frontEl, cardData) {
|
||||||
|
frontEl.className = 'card-face card-face-front';
|
||||||
|
|
||||||
|
if (!cardData) return;
|
||||||
|
|
||||||
|
if (cardData.rank === '★') {
|
||||||
|
frontEl.classList.add('joker');
|
||||||
|
const jokerIcon = cardData.suit === 'hearts' ? '🐉' : '👹';
|
||||||
|
frontEl.innerHTML = `<span class="joker-icon">${jokerIcon}</span><span class="joker-label">Joker</span>`;
|
||||||
|
} else {
|
||||||
|
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
|
||||||
|
frontEl.classList.add(isRed ? 'red' : 'black');
|
||||||
|
const suitSymbol = this.getSuitSymbol(cardData.suit);
|
||||||
|
frontEl.innerHTML = `${cardData.rank}<br>${suitSymbol}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSuitSymbol(suit) {
|
||||||
|
const symbols = { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' };
|
||||||
|
return symbols[suit] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate discarding a card (from hand to discard pile) - called for other players
|
||||||
|
async animateDiscard(movement) {
|
||||||
|
const { card, fromPlayerId, fromPosition } = movement;
|
||||||
|
|
||||||
|
// If no specific position, animate from opponent's area
|
||||||
|
const discardRect = this.getLocationRect('discard');
|
||||||
|
if (!discardRect) return;
|
||||||
|
|
||||||
|
let startRect;
|
||||||
|
|
||||||
|
if (fromPosition !== null && fromPosition !== undefined) {
|
||||||
|
startRect = this.getSlotRect(fromPlayerId, fromPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: use discard position offset upward
|
||||||
|
if (!startRect) {
|
||||||
|
startRect = {
|
||||||
|
left: discardRect.left,
|
||||||
|
top: discardRect.top - 80,
|
||||||
|
width: discardRect.width,
|
||||||
|
height: discardRect.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create animation card
|
||||||
|
const animCard = this.createAnimCard();
|
||||||
|
this.cardManager.cardLayer.appendChild(animCard);
|
||||||
|
this.setCardPosition(animCard, startRect);
|
||||||
|
|
||||||
|
const inner = animCard.querySelector('.card-inner');
|
||||||
|
const front = animCard.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Show the card that was discarded
|
||||||
|
this.setCardFront(front, card);
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
|
||||||
|
// Move to discard
|
||||||
|
this.playSound('card');
|
||||||
|
animCard.classList.add('moving');
|
||||||
|
this.setCardPosition(animCard, discardRect);
|
||||||
|
await this.delay(this.timing.moveDuration);
|
||||||
|
animCard.classList.remove('moving');
|
||||||
|
|
||||||
|
await this.delay(this.timing.pauseAfterMove);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
animCard.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate drawing from deck
|
||||||
|
async animateDrawDeck(movement) {
|
||||||
|
const { playerId } = movement;
|
||||||
|
|
||||||
|
const deckRect = this.getLocationRect('deck');
|
||||||
|
const holdingRect = this.getLocationRect('holding');
|
||||||
|
|
||||||
|
if (!deckRect || !holdingRect) return;
|
||||||
|
|
||||||
|
// Create animation card at deck position (face down)
|
||||||
|
const animCard = this.createAnimCard();
|
||||||
|
this.cardManager.cardLayer.appendChild(animCard);
|
||||||
|
this.setCardPosition(animCard, deckRect);
|
||||||
|
|
||||||
|
const inner = animCard.querySelector('.card-inner');
|
||||||
|
inner.classList.add('flipped'); // Show back
|
||||||
|
|
||||||
|
// Move to holding position
|
||||||
|
this.playSound('card');
|
||||||
|
await this.delay(50);
|
||||||
|
|
||||||
|
animCard.classList.add('moving');
|
||||||
|
this.setCardPosition(animCard, holdingRect);
|
||||||
|
await this.delay(this.timing.moveDuration);
|
||||||
|
animCard.classList.remove('moving');
|
||||||
|
|
||||||
|
// The card stays face down until the player decides what to do
|
||||||
|
// (the actual card reveal happens when server sends card_drawn)
|
||||||
|
|
||||||
|
await this.delay(this.timing.pauseAfterMove);
|
||||||
|
|
||||||
|
// Clean up - renderGame will show the holding card state
|
||||||
|
animCard.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate drawing from discard
|
||||||
|
async animateDrawDiscard(movement) {
|
||||||
|
const { playerId } = movement;
|
||||||
|
|
||||||
|
// Discard to holding is mostly visual feedback
|
||||||
|
// The card "lifts" slightly
|
||||||
|
|
||||||
|
const discardRect = this.getLocationRect('discard');
|
||||||
|
const holdingRect = this.getLocationRect('holding');
|
||||||
|
|
||||||
|
if (!discardRect || !holdingRect) return;
|
||||||
|
|
||||||
|
// Just play sound - visual handled by CSS :holding state
|
||||||
|
this.playSound('card');
|
||||||
|
|
||||||
|
await this.delay(this.timing.moveDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if animations are currently playing
|
||||||
|
isAnimating() {
|
||||||
|
return this.animationInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the queue (for interruption)
|
||||||
|
clear() {
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility delay
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in app.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = AnimationQueue;
|
||||||
|
}
|
||||||
937
client/app.js
937
client/app.js
File diff suppressed because it is too large
Load Diff
259
client/card-manager.js
Normal file
259
client/card-manager.js
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
// CardManager - Manages persistent card DOM elements
|
||||||
|
// Cards are REAL elements that exist in ONE place and move between locations
|
||||||
|
|
||||||
|
class CardManager {
|
||||||
|
constructor(cardLayer) {
|
||||||
|
this.cardLayer = cardLayer;
|
||||||
|
// Map of "playerId-position" -> card element
|
||||||
|
this.handCards = new Map();
|
||||||
|
// Special cards
|
||||||
|
this.deckCard = null;
|
||||||
|
this.discardCard = null;
|
||||||
|
this.holdingCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize cards for a game state
|
||||||
|
initializeCards(gameState, playerId, getSlotRect, getDeckRect, getDiscardRect) {
|
||||||
|
this.clear();
|
||||||
|
|
||||||
|
// Create cards for each player's hand
|
||||||
|
for (const player of gameState.players) {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const card = player.cards[i];
|
||||||
|
const slotKey = `${player.id}-${i}`;
|
||||||
|
const cardEl = this.createCardElement(card);
|
||||||
|
|
||||||
|
// Position at slot (will be updated later if rect not ready)
|
||||||
|
const rect = getSlotRect(player.id, i);
|
||||||
|
if (rect && rect.width > 0) {
|
||||||
|
this.positionCard(cardEl, rect);
|
||||||
|
} else {
|
||||||
|
// Start invisible, will be positioned by updateAllPositions
|
||||||
|
cardEl.style.opacity = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handCards.set(slotKey, {
|
||||||
|
element: cardEl,
|
||||||
|
cardData: card,
|
||||||
|
playerId: player.id,
|
||||||
|
position: i
|
||||||
|
});
|
||||||
|
|
||||||
|
this.cardLayer.appendChild(cardEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a card DOM element with 3D flip structure
|
||||||
|
createCardElement(cardData) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'real-card';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="card-inner">
|
||||||
|
<div class="card-face card-face-front"></div>
|
||||||
|
<div class="card-face card-face-back"><span>?</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.updateCardAppearance(card, cardData);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update card visual state (face up/down, content)
|
||||||
|
updateCardAppearance(cardEl, cardData) {
|
||||||
|
const inner = cardEl.querySelector('.card-inner');
|
||||||
|
const front = cardEl.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Reset front classes
|
||||||
|
front.className = 'card-face card-face-front';
|
||||||
|
|
||||||
|
if (!cardData || !cardData.face_up || !cardData.rank) {
|
||||||
|
// Face down or no data
|
||||||
|
inner.classList.add('flipped');
|
||||||
|
front.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
// Face up with data
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
|
||||||
|
if (cardData.rank === '★') {
|
||||||
|
front.classList.add('joker');
|
||||||
|
const icon = cardData.suit === 'hearts' ? '🐉' : '👹';
|
||||||
|
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||||
|
} else {
|
||||||
|
const isRed = cardData.suit === 'hearts' || cardData.suit === 'diamonds';
|
||||||
|
front.classList.add(isRed ? 'red' : 'black');
|
||||||
|
front.innerHTML = `${cardData.rank}<br>${this.getSuitSymbol(cardData.suit)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSuitSymbol(suit) {
|
||||||
|
return { hearts: '♥', diamonds: '♦', clubs: '♣', spades: '♠' }[suit] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position a card at a rect
|
||||||
|
positionCard(cardEl, rect, animate = false) {
|
||||||
|
if (animate) {
|
||||||
|
cardEl.classList.add('moving');
|
||||||
|
}
|
||||||
|
|
||||||
|
cardEl.style.left = `${rect.left}px`;
|
||||||
|
cardEl.style.top = `${rect.top}px`;
|
||||||
|
cardEl.style.width = `${rect.width}px`;
|
||||||
|
cardEl.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
setTimeout(() => cardEl.classList.remove('moving'), 350);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a hand card by player and position
|
||||||
|
getHandCard(playerId, position) {
|
||||||
|
return this.handCards.get(`${playerId}-${position}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all card positions to match current slot positions
|
||||||
|
// Returns number of cards successfully positioned
|
||||||
|
updateAllPositions(getSlotRect) {
|
||||||
|
let positioned = 0;
|
||||||
|
for (const [key, cardInfo] of this.handCards) {
|
||||||
|
const rect = getSlotRect(cardInfo.playerId, cardInfo.position);
|
||||||
|
if (rect && rect.width > 0) {
|
||||||
|
this.positionCard(cardInfo.element, rect, false);
|
||||||
|
// Restore visibility if it was hidden
|
||||||
|
cardInfo.element.style.opacity = '1';
|
||||||
|
positioned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positioned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate a card flip
|
||||||
|
async flipCard(playerId, position, newCardData, duration = 400) {
|
||||||
|
const cardInfo = this.getHandCard(playerId, position);
|
||||||
|
if (!cardInfo) return;
|
||||||
|
|
||||||
|
const inner = cardInfo.element.querySelector('.card-inner');
|
||||||
|
const front = cardInfo.element.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Set up the front content before flip
|
||||||
|
front.className = 'card-face card-face-front';
|
||||||
|
if (newCardData.rank === '★') {
|
||||||
|
front.classList.add('joker');
|
||||||
|
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
|
||||||
|
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||||
|
} else {
|
||||||
|
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
|
||||||
|
front.classList.add(isRed ? 'red' : 'black');
|
||||||
|
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate flip
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
|
||||||
|
await this.delay(duration);
|
||||||
|
|
||||||
|
cardInfo.cardData = newCardData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate a swap: hand card goes to discard, new card comes to hand
|
||||||
|
async animateSwap(playerId, position, oldCardData, newCardData, getSlotRect, getDiscardRect, duration = 300) {
|
||||||
|
const cardInfo = this.getHandCard(playerId, position);
|
||||||
|
if (!cardInfo) return;
|
||||||
|
|
||||||
|
const slotRect = getSlotRect(playerId, position);
|
||||||
|
const discardRect = getDiscardRect();
|
||||||
|
|
||||||
|
if (!slotRect || !discardRect) return;
|
||||||
|
if (!oldCardData || !oldCardData.rank) {
|
||||||
|
// Can't animate without card data - just update appearance
|
||||||
|
this.updateCardAppearance(cardInfo.element, newCardData);
|
||||||
|
cardInfo.cardData = newCardData;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardEl = cardInfo.element;
|
||||||
|
const inner = cardEl.querySelector('.card-inner');
|
||||||
|
const front = cardEl.querySelector('.card-face-front');
|
||||||
|
|
||||||
|
// Step 1: If face down, flip to reveal the old card
|
||||||
|
if (!oldCardData.face_up) {
|
||||||
|
// Set front to show old card
|
||||||
|
front.className = 'card-face card-face-front';
|
||||||
|
if (oldCardData.rank === '★') {
|
||||||
|
front.classList.add('joker');
|
||||||
|
const icon = oldCardData.suit === 'hearts' ? '🐉' : '👹';
|
||||||
|
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||||
|
} else {
|
||||||
|
const isRed = oldCardData.suit === 'hearts' || oldCardData.suit === 'diamonds';
|
||||||
|
front.classList.add(isRed ? 'red' : 'black');
|
||||||
|
front.innerHTML = `${oldCardData.rank}<br>${this.getSuitSymbol(oldCardData.suit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.classList.remove('flipped');
|
||||||
|
await this.delay(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Move card to discard
|
||||||
|
cardEl.classList.add('moving');
|
||||||
|
this.positionCard(cardEl, discardRect);
|
||||||
|
await this.delay(duration + 50);
|
||||||
|
cardEl.classList.remove('moving');
|
||||||
|
|
||||||
|
// Pause to show the discarded card
|
||||||
|
await this.delay(250);
|
||||||
|
|
||||||
|
// Step 3: Update card to show new card and move back to hand
|
||||||
|
front.className = 'card-face card-face-front';
|
||||||
|
if (newCardData.rank === '★') {
|
||||||
|
front.classList.add('joker');
|
||||||
|
const icon = newCardData.suit === 'hearts' ? '🐉' : '👹';
|
||||||
|
front.innerHTML = `<span class="joker-icon">${icon}</span><span class="joker-label">Joker</span>`;
|
||||||
|
} else {
|
||||||
|
const isRed = newCardData.suit === 'hearts' || newCardData.suit === 'diamonds';
|
||||||
|
front.classList.add(isRed ? 'red' : 'black');
|
||||||
|
front.innerHTML = `${newCardData.rank}<br>${this.getSuitSymbol(newCardData.suit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newCardData.face_up) {
|
||||||
|
inner.classList.add('flipped');
|
||||||
|
}
|
||||||
|
|
||||||
|
cardEl.classList.add('moving');
|
||||||
|
this.positionCard(cardEl, slotRect);
|
||||||
|
await this.delay(duration + 50);
|
||||||
|
cardEl.classList.remove('moving');
|
||||||
|
|
||||||
|
cardInfo.cardData = newCardData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set holding state for a card (drawn card highlight)
|
||||||
|
setHolding(playerId, position, isHolding) {
|
||||||
|
const cardInfo = this.getHandCard(playerId, position);
|
||||||
|
if (cardInfo) {
|
||||||
|
cardInfo.element.classList.toggle('holding', isHolding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all cards
|
||||||
|
clear() {
|
||||||
|
for (const [key, cardInfo] of this.handCards) {
|
||||||
|
cardInfo.element.remove();
|
||||||
|
}
|
||||||
|
this.handCards.clear();
|
||||||
|
|
||||||
|
if (this.holdingCard) {
|
||||||
|
this.holdingCard.remove();
|
||||||
|
this.holdingCard = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = CardManager;
|
||||||
|
}
|
||||||
@ -200,19 +200,24 @@
|
|||||||
|
|
||||||
<!-- Game Screen -->
|
<!-- Game Screen -->
|
||||||
<div id="game-screen" class="screen">
|
<div id="game-screen" class="screen">
|
||||||
|
<!-- Card layer for persistent card elements -->
|
||||||
|
<div id="card-layer"></div>
|
||||||
<div class="game-layout">
|
<div class="game-layout">
|
||||||
<div class="game-main">
|
<div class="game-main">
|
||||||
<div class="game-header">
|
<div class="game-header">
|
||||||
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
|
<div class="round-info">Hole <span id="current-round">1</span>/<span id="total-rounds">9</span></div>
|
||||||
|
<div class="game-header-center">
|
||||||
<div id="active-rules-bar" class="active-rules-bar hidden">
|
<div id="active-rules-bar" class="active-rules-bar hidden">
|
||||||
<span class="rules-label">Rules:</span>
|
<span class="rules-label">Rules:</span>
|
||||||
<span id="active-rules-list" class="rules-list"></span>
|
<span id="active-rules-list" class="rules-list"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="turn-info" id="turn-info">Your turn</div>
|
<div class="header-status">
|
||||||
<div class="score-info">Showing: <span id="your-score">0</span></div>
|
<div id="status-message" class="status-message"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
|
|
||||||
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
<button id="mute-btn" class="mute-btn" title="Toggle sound">🔊</button>
|
||||||
|
<button id="leave-game-btn" class="btn btn-small btn-danger">Leave</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -225,22 +230,30 @@
|
|||||||
<div id="deck" class="card card-back">
|
<div id="deck" class="card card-back">
|
||||||
<span>?</span>
|
<span>?</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="discard-stack">
|
||||||
<div id="discard" class="card">
|
<div id="discard" class="card">
|
||||||
<span id="discard-content"></span>
|
<span id="discard-content"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="discard-btn" class="btn btn-small hidden">Discard</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="drawn-card-area" class="hidden">
|
|
||||||
<div id="drawn-card" class="card"></div>
|
|
||||||
<button id="discard-btn" class="btn btn-small">Discard</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-section">
|
<div class="player-section">
|
||||||
<div id="flip-prompt" class="flip-prompt hidden"></div>
|
|
||||||
<div class="player-area">
|
<div class="player-area">
|
||||||
|
<h4 id="player-header">You<span id="your-score" class="player-showing">0</span></h4>
|
||||||
<div id="player-cards" class="card-grid"></div>
|
<div id="player-cards" class="card-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="toast" class="toast hidden"></div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Legacy swap animation overlay (kept for rollback) -->
|
||||||
|
<div id="swap-animation" class="swap-animation hidden">
|
||||||
|
<div id="swap-card-from-hand" class="swap-card">
|
||||||
|
<div class="swap-card-inner">
|
||||||
|
<div class="swap-card-front"></div>
|
||||||
|
<div class="swap-card-back">?</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -287,6 +300,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="card-manager.js"></script>
|
||||||
|
<script src="state-differ.js"></script>
|
||||||
|
<script src="animation-queue.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
164
client/state-differ.js
Normal file
164
client/state-differ.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
// StateDiffer - Detects what changed between game states
|
||||||
|
// Generates movement instructions for the animation queue
|
||||||
|
|
||||||
|
class StateDiffer {
|
||||||
|
constructor() {
|
||||||
|
this.previousState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare old and new state, return array of movements
|
||||||
|
diff(oldState, newState) {
|
||||||
|
const movements = [];
|
||||||
|
|
||||||
|
if (!oldState || !newState) {
|
||||||
|
return movements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for initial flip phase - still animate initial flips
|
||||||
|
if (oldState.waiting_for_initial_flip && !newState.waiting_for_initial_flip) {
|
||||||
|
// Initial flip just completed - detect which cards were flipped
|
||||||
|
for (const newPlayer of newState.players) {
|
||||||
|
const oldPlayer = oldState.players.find(p => p.id === newPlayer.id);
|
||||||
|
if (oldPlayer) {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (!oldPlayer.cards[i].face_up && newPlayer.cards[i].face_up) {
|
||||||
|
movements.push({
|
||||||
|
type: 'flip',
|
||||||
|
playerId: newPlayer.id,
|
||||||
|
position: i,
|
||||||
|
faceUp: true,
|
||||||
|
card: newPlayer.cards[i]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return movements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still in initial flip selection - no animations
|
||||||
|
if (newState.waiting_for_initial_flip) {
|
||||||
|
return movements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for turn change - the previous player just acted
|
||||||
|
const previousPlayerId = oldState.current_player_id;
|
||||||
|
const currentPlayerId = newState.current_player_id;
|
||||||
|
const turnChanged = previousPlayerId !== currentPlayerId;
|
||||||
|
|
||||||
|
// Detect if a swap happened (discard changed AND a hand position changed)
|
||||||
|
const newTop = newState.discard_top;
|
||||||
|
const oldTop = oldState.discard_top;
|
||||||
|
const discardChanged = newTop && (!oldTop ||
|
||||||
|
oldTop.rank !== newTop.rank ||
|
||||||
|
oldTop.suit !== newTop.suit);
|
||||||
|
|
||||||
|
// Find hand changes for the player who just played
|
||||||
|
if (turnChanged && previousPlayerId) {
|
||||||
|
const oldPlayer = oldState.players.find(p => p.id === previousPlayerId);
|
||||||
|
const newPlayer = newState.players.find(p => p.id === previousPlayerId);
|
||||||
|
|
||||||
|
if (oldPlayer && newPlayer) {
|
||||||
|
// First pass: detect swaps (card identity changed)
|
||||||
|
const swappedPositions = new Set();
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const oldCard = oldPlayer.cards[i];
|
||||||
|
const newCard = newPlayer.cards[i];
|
||||||
|
|
||||||
|
// Card identity changed = swap happened at this position
|
||||||
|
if (this.cardIdentityChanged(oldCard, newCard)) {
|
||||||
|
swappedPositions.add(i);
|
||||||
|
|
||||||
|
// Use discard_top for the revealed card (more reliable for opponents)
|
||||||
|
const revealedCard = newState.discard_top || { ...oldCard, face_up: true };
|
||||||
|
|
||||||
|
movements.push({
|
||||||
|
type: 'swap',
|
||||||
|
playerId: previousPlayerId,
|
||||||
|
position: i,
|
||||||
|
oldCard: revealedCard,
|
||||||
|
newCard: newCard
|
||||||
|
});
|
||||||
|
break; // Only one swap per turn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: detect flips (card went from face_down to face_up, not a swap)
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (swappedPositions.has(i)) continue; // Skip if already detected as swap
|
||||||
|
|
||||||
|
const oldCard = oldPlayer.cards[i];
|
||||||
|
const newCard = newPlayer.cards[i];
|
||||||
|
|
||||||
|
if (this.cardWasFlipped(oldCard, newCard)) {
|
||||||
|
movements.push({
|
||||||
|
type: 'flip',
|
||||||
|
playerId: previousPlayerId,
|
||||||
|
position: i,
|
||||||
|
faceUp: true,
|
||||||
|
card: newCard
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect drawing (current player just drew)
|
||||||
|
if (newState.has_drawn_card && !oldState.has_drawn_card) {
|
||||||
|
// Discard pile decreased = drew from discard
|
||||||
|
const drewFromDiscard = !newState.discard_top ||
|
||||||
|
(oldState.discard_top &&
|
||||||
|
(!newState.discard_top ||
|
||||||
|
oldState.discard_top.rank !== newState.discard_top.rank ||
|
||||||
|
oldState.discard_top.suit !== newState.discard_top.suit));
|
||||||
|
|
||||||
|
movements.push({
|
||||||
|
type: drewFromDiscard ? 'draw-discard' : 'draw-deck',
|
||||||
|
playerId: currentPlayerId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return movements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the card identity (rank+suit) changed between old and new
|
||||||
|
// Returns true if definitely different cards, false if same or unknown
|
||||||
|
cardIdentityChanged(oldCard, newCard) {
|
||||||
|
// If both have rank/suit data, compare directly
|
||||||
|
if (oldCard.rank && newCard.rank) {
|
||||||
|
return oldCard.rank !== newCard.rank || oldCard.suit !== newCard.suit;
|
||||||
|
}
|
||||||
|
// Can't determine - assume same card (flip, not swap)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a card was just flipped (same card, now face up)
|
||||||
|
cardWasFlipped(oldCard, newCard) {
|
||||||
|
return !oldCard.face_up && newCard.face_up;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a summary of movements for debugging
|
||||||
|
summarize(movements) {
|
||||||
|
return movements.map(m => {
|
||||||
|
switch (m.type) {
|
||||||
|
case 'flip':
|
||||||
|
return `Flip: Player ${m.playerId} position ${m.position}`;
|
||||||
|
case 'swap':
|
||||||
|
return `Swap: Player ${m.playerId} position ${m.position}`;
|
||||||
|
case 'discard':
|
||||||
|
return `Discard: ${m.card.rank}${m.card.suit} from player ${m.fromPlayerId}`;
|
||||||
|
case 'draw-deck':
|
||||||
|
return `Draw from deck: Player ${m.playerId}`;
|
||||||
|
case 'draw-discard':
|
||||||
|
return `Draw from discard: Player ${m.playerId}`;
|
||||||
|
default:
|
||||||
|
return `Unknown: ${m.type}`;
|
||||||
|
}
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for use in app.js
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = StateDiffer;
|
||||||
|
}
|
||||||
484
client/style.css
484
client/style.css
@ -462,13 +462,11 @@ input::placeholder {
|
|||||||
|
|
||||||
/* Game Screen */
|
/* Game Screen */
|
||||||
.game-header {
|
.game-header {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: auto 1fr auto auto auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
justify-content: space-between;
|
||||||
padding: 10px 25px;
|
padding: 10px 20px;
|
||||||
background: rgba(0,0,0,0.35);
|
background: rgba(0,0,0,0.35);
|
||||||
border-radius: 0;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin-left: calc(-50vw + 50%);
|
margin-left: calc(-50vw + 50%);
|
||||||
@ -480,20 +478,22 @@ input::placeholder {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-header-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.game-header .turn-info {
|
.game-header .turn-info {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header .score-info {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-header .header-buttons {
|
.game-header .header-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#leave-game-btn {
|
#leave-game-btn {
|
||||||
@ -602,8 +602,24 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-front.joker {
|
.card-front.joker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-front.joker .joker-icon {
|
||||||
|
font-size: 1.6em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-front.joker .joker-label {
|
||||||
|
font-size: 0.45em;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
color: #9b59b6;
|
color: #9b59b6;
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.clickable {
|
.card.clickable {
|
||||||
@ -762,6 +778,25 @@ input::placeholder {
|
|||||||
border: 2px solid #ddd;
|
border: 2px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Holding state - when player has drawn a card */
|
||||||
|
#discard.holding {
|
||||||
|
background: #fff;
|
||||||
|
border: 3px solid #f4a460;
|
||||||
|
box-shadow: 0 0 15px rgba(244, 164, 96, 0.6);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discard-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discard-stack .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
#deck.disabled,
|
#deck.disabled,
|
||||||
#discard.disabled {
|
#discard.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
@ -777,45 +812,140 @@ input::placeholder {
|
|||||||
|
|
||||||
/* Card flip animation for discard pile */
|
/* Card flip animation for discard pile */
|
||||||
.card-flip-in {
|
.card-flip-in {
|
||||||
animation: cardFlipIn 0.4s ease-out;
|
animation: cardFlipIn 0.5s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cardFlipIn {
|
@keyframes cardFlipIn {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1.3) rotateY(90deg);
|
transform: scale(1.4) translateY(-20px);
|
||||||
opacity: 0.5;
|
opacity: 0;
|
||||||
box-shadow: 0 0 30px rgba(244, 164, 96, 0.8);
|
box-shadow: 0 0 40px rgba(244, 164, 96, 1);
|
||||||
}
|
}
|
||||||
50% {
|
30% {
|
||||||
transform: scale(1.15) rotateY(0deg);
|
transform: scale(1.25) translateY(-10px);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
box-shadow: 0 0 25px rgba(244, 164, 96, 0.6);
|
box-shadow: 0 0 35px rgba(244, 164, 96, 0.9);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.1) translateY(0);
|
||||||
|
box-shadow: 0 0 20px rgba(244, 164, 96, 0.5);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1) rotateY(0deg);
|
transform: scale(1) translateY(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#drawn-card-area {
|
/* Swap animation overlay */
|
||||||
display: flex;
|
.swap-animation {
|
||||||
flex-direction: column;
|
position: fixed;
|
||||||
align-items: center;
|
top: 0;
|
||||||
gap: 8px;
|
left: 0;
|
||||||
padding: 10px 12px;
|
width: 100%;
|
||||||
background: rgba(0,0,0,0.25);
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-animation.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card {
|
||||||
|
position: absolute;
|
||||||
|
width: 70px;
|
||||||
|
height: 98px;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card.flipping .swap-card-inner {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card-front,
|
||||||
|
.swap-card-back {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#drawn-card-area .card {
|
.swap-card-back {
|
||||||
width: clamp(80px, 7vw, 120px);
|
background: linear-gradient(135deg, #c0392b 0%, #922b21 100%);
|
||||||
height: clamp(112px, 9.8vw, 168px);
|
color: rgba(255,255,255,0.4);
|
||||||
font-size: clamp(2.4rem, 3.2vw, 4rem);
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#drawn-card-area .btn {
|
.swap-card-front {
|
||||||
white-space: nowrap;
|
background: linear-gradient(145deg, #fff 0%, #f0f0f0 100%);
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
font-size: 2rem;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #2c3e50;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card-front.red {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card-front.black {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card-front.joker {
|
||||||
|
color: #9b59b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card-front .joker-icon {
|
||||||
|
font-size: 1.6em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card-front .joker-label {
|
||||||
|
font-size: 0.45em;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Movement animation */
|
||||||
|
.swap-card.flipping {
|
||||||
|
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-card.moving {
|
||||||
|
transition: top 0.4s cubic-bezier(0.4, 0, 0.2, 1), left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s ease-out;
|
||||||
|
transform: scale(1.1) rotate(-5deg);
|
||||||
|
filter: drop-shadow(0 0 25px rgba(244, 164, 96, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card in hand fading during swap */
|
||||||
|
.card.swap-out {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discard fading during swap */
|
||||||
|
#discard.swap-to-hand {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Player Area */
|
/* Player Area */
|
||||||
@ -856,7 +986,8 @@ input::placeholder {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opponent-showing {
|
.opponent-showing,
|
||||||
|
.player-showing {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
@ -866,6 +997,18 @@ input::placeholder {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Player area header - matches opponent style */
|
||||||
|
.player-area h4 {
|
||||||
|
font-size: clamp(0.8rem, 1vw, 1.1rem);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
padding: clamp(4px, 0.4vw, 8px) clamp(10px, 1vw, 16px);
|
||||||
|
background: rgba(244, 164, 96, 0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.opponent-area .card-grid {
|
.opponent-area .card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, clamp(45px, 4vw, 75px));
|
grid-template-columns: repeat(3, clamp(45px, 4vw, 75px));
|
||||||
@ -885,25 +1028,27 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Toast Notification */
|
/* Toast Notification */
|
||||||
.toast {
|
/* Header status area */
|
||||||
background: rgba(0, 0, 0, 0.9);
|
.header-status {
|
||||||
color: #fff;
|
display: flex;
|
||||||
padding: 8px 20px;
|
align-items: center;
|
||||||
border-radius: 6px;
|
justify-content: center;
|
||||||
font-size: 0.85rem;
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 8px;
|
white-space: nowrap;
|
||||||
animation: toastIn 0.3s ease;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.hidden {
|
.status-message.your-turn {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.your-turn {
|
|
||||||
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
|
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
|
||||||
color: #1a472a;
|
color: #1a472a;
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes toastIn {
|
@keyframes toastIn {
|
||||||
@ -915,12 +1060,12 @@ input::placeholder {
|
|||||||
.flip-prompt {
|
.flip-prompt {
|
||||||
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
|
background: linear-gradient(135deg, #f4a460 0%, #e8914d 100%);
|
||||||
color: #1a472a;
|
color: #1a472a;
|
||||||
padding: 8px 16px;
|
padding: 6px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 8px;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flip-prompt.hidden {
|
.flip-prompt.hidden {
|
||||||
@ -955,44 +1100,40 @@ input::placeholder {
|
|||||||
/* Side Panels - positioned in bottom corners */
|
/* Side Panels - positioned in bottom corners */
|
||||||
.side-panel {
|
.side-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 20px;
|
bottom: 15px;
|
||||||
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
|
background: linear-gradient(145deg, rgba(15, 50, 35, 0.92) 0%, rgba(8, 30, 20, 0.95) 100%);
|
||||||
border-radius: 16px;
|
border-radius: 10px;
|
||||||
padding: 18px 20px;
|
padding: 10px 12px;
|
||||||
width: 263px;
|
width: 200px;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(244, 164, 96, 0.25);
|
border: 1px solid rgba(244, 164, 96, 0.25);
|
||||||
box-shadow:
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
0 4px 24px rgba(0, 0, 0, 0.5),
|
|
||||||
0 0 40px rgba(244, 164, 96, 0.08),
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-panel.left-panel {
|
.side-panel.left-panel {
|
||||||
left: 20px;
|
left: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-panel.right-panel {
|
.side-panel.right-panel {
|
||||||
right: 20px;
|
right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-panel > h4 {
|
.side-panel > h4 {
|
||||||
font-size: 1rem;
|
font-size: 0.7rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 8px;
|
||||||
color: #f4a460;
|
color: #f4a460;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.15em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
|
||||||
border-bottom: 1px solid rgba(244, 164, 96, 0.2);
|
border-bottom: 1px solid rgba(244, 164, 96, 0.2);
|
||||||
padding-bottom: 12px;
|
padding-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Standings list - two sections, top 4 each */
|
/* Standings list - two sections, top 4 each */
|
||||||
.standings-section {
|
.standings-section {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-section:last-child {
|
.standings-section:last-child {
|
||||||
@ -1000,27 +1141,27 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.standings-title {
|
.standings-title {
|
||||||
font-size: 0.7rem;
|
font-size: 0.6rem;
|
||||||
color: rgba(255,255,255,0.5);
|
color: rgba(255,255,255,0.5);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
padding-bottom: 3px;
|
padding-bottom: 2px;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 2px;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-list .rank-row {
|
.standings-list .rank-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 22px 1fr 36px;
|
grid-template-columns: 18px 1fr 30px;
|
||||||
gap: 4px;
|
gap: 3px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.7rem;
|
||||||
padding: 2px 0;
|
padding: 1px 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-list .rank-pos {
|
.standings-list .rank-pos {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings-list .rank-name {
|
.standings-list .rank-name {
|
||||||
@ -1031,7 +1172,7 @@ input::placeholder {
|
|||||||
|
|
||||||
.standings-list .rank-val {
|
.standings-list .rank-val {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 0.75rem;
|
font-size: 0.65rem;
|
||||||
color: rgba(255,255,255,0.7);
|
color: rgba(255,255,255,0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1047,12 +1188,12 @@ input::placeholder {
|
|||||||
.side-panel table {
|
.side-panel table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 1rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-panel th,
|
.side-panel th,
|
||||||
.side-panel td {
|
.side-panel td {
|
||||||
padding: 8px 6px;
|
padding: 4px 3px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
}
|
}
|
||||||
@ -1060,9 +1201,9 @@ input::placeholder {
|
|||||||
.side-panel th {
|
.side-panel th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(0,0,0,0.25);
|
background: rgba(0,0,0,0.25);
|
||||||
font-size: 0.85rem;
|
font-size: 0.65rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.03em;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1081,15 +1222,15 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons {
|
.game-buttons {
|
||||||
margin-top: 12px;
|
margin-top: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-buttons .btn {
|
.game-buttons .btn {
|
||||||
font-size: 0.8rem;
|
font-size: 0.7rem;
|
||||||
padding: 10px 12px;
|
padding: 6px 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1347,6 +1488,181 @@ input::placeholder {
|
|||||||
.suit-clubs::after { content: "♣"; }
|
.suit-clubs::after { content: "♣"; }
|
||||||
.suit-spades::after { content: "♠"; }
|
.suit-spades::after { content: "♠"; }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
New Card System - Persistent Card Elements
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Card Layer - container for all persistent cards */
|
||||||
|
#card-layer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 500;
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#card-layer .real-card {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Slot - only used when USE_NEW_CARD_SYSTEM is true */
|
||||||
|
.card-slot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Real Card - persistent card element with 3D structure */
|
||||||
|
.real-card {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 6px;
|
||||||
|
perspective: 1000px;
|
||||||
|
z-index: 501;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card:hover {
|
||||||
|
z-index: 510;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-inner.flipped {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-face {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Front */
|
||||||
|
.real-card .card-face-front {
|
||||||
|
background: linear-gradient(145deg, #fff 0%, #f5f5f5 100%);
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
color: #333;
|
||||||
|
font-size: clamp(1.8rem, 2.2vw, 2.8rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-face-front.red {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-face-front.black {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-face-front.joker {
|
||||||
|
color: #9b59b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-face-front .joker-icon {
|
||||||
|
font-size: 1.5em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card .card-face-front .joker-label {
|
||||||
|
font-size: 0.4em;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Back */
|
||||||
|
.real-card .card-face-back {
|
||||||
|
background-color: #c41e3a;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, rgba(255,255,255,0.25) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, rgba(255,255,255,0.25) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.25) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.25) 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
|
||||||
|
border: 3px solid #8b1528;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: clamp(1.8rem, 2.5vw, 3rem);
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card States */
|
||||||
|
.real-card.moving,
|
||||||
|
.real-card.anim-card.moving {
|
||||||
|
z-index: 600;
|
||||||
|
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 0.3s ease-out;
|
||||||
|
filter: drop-shadow(0 0 20px rgba(244, 164, 96, 0.8));
|
||||||
|
transform: scale(1.08) rotate(-3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation card - temporary cards used for animations */
|
||||||
|
.real-card.anim-card {
|
||||||
|
z-index: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card.anim-card .card-inner {
|
||||||
|
transition: transform 0.4s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card.holding {
|
||||||
|
z-index: 550;
|
||||||
|
box-shadow: 0 0 20px rgba(244, 164, 96, 0.6),
|
||||||
|
0 4px 15px rgba(0, 0, 0, 0.4);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card.clickable {
|
||||||
|
box-shadow: 0 0 0 2px rgba(244, 164, 96, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card.clickable:hover {
|
||||||
|
box-shadow: 0 0 0 3px #f4a460,
|
||||||
|
0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card.selected {
|
||||||
|
box-shadow: 0 0 0 4px #fff, 0 0 15px 5px #f4a460;
|
||||||
|
transform: scale(1.06);
|
||||||
|
z-index: 520;
|
||||||
|
}
|
||||||
|
|
||||||
|
.real-card.drawing {
|
||||||
|
z-index: 590;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deck card styling in new system */
|
||||||
|
#deck.new-system {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discard styling for new system */
|
||||||
|
#discard.new-system.holding {
|
||||||
|
box-shadow: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 2px dashed rgba(244, 164, 96, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
115
pyproject.toml
Normal file
115
pyproject.toml
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
[project]
|
||||||
|
name = "golfgame"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "6-Card Golf card game with AI opponents"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "alee"}
|
||||||
|
]
|
||||||
|
keywords = ["card-game", "golf", "websocket", "fastapi", "ai"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Framework :: FastAPI",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Games/Entertainment :: Board Games",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.109.0",
|
||||||
|
"uvicorn[standard]>=0.27.0",
|
||||||
|
"websockets>=12.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
golfgame = "server.main:run"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/alee/golfgame"
|
||||||
|
Repository = "https://github.com/alee/golfgame"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["server"]
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Tool Configuration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["server"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # Pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long (handled by formatter)
|
||||||
|
"B008", # do not perform function calls in argument defaults
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["server"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Game Configuration Defaults
|
||||||
|
# ============================================================================
|
||||||
|
# These can be overridden via environment variables
|
||||||
|
# See .env.example for documentation
|
||||||
|
|
||||||
|
[tool.golfgame]
|
||||||
|
# Server settings
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 8000
|
||||||
|
debug = false
|
||||||
|
log_level = "INFO"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url = "sqlite:///server/games.db"
|
||||||
|
|
||||||
|
# Game defaults
|
||||||
|
default_rounds = 9
|
||||||
|
max_players_per_room = 6
|
||||||
|
room_timeout_minutes = 60
|
||||||
|
|
||||||
|
# Card values (standard 6-Card Golf)
|
||||||
|
# These are defined in server/constants.py
|
||||||
5
pyvenv.cfg
Normal file
5
pyvenv.cfg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
home = /home/alee/.pyenv/versions/3.12.0/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.12.0
|
||||||
|
executable = /home/alee/.pyenv/versions/3.12.0/bin/python3.12
|
||||||
|
command = /home/alee/.pyenv/versions/3.12.0/bin/python -m venv /home/alee/Sources/golfgame
|
||||||
845
server/RULES.md
845
server/RULES.md
@ -1,6 +1,19 @@
|
|||||||
# 6-Card Golf Rules
|
# 6-Card Golf Rules
|
||||||
|
|
||||||
This document defines the canonical rules implemented in this game engine, based on standard 6-Card Golf rules from [Pagat.com](https://www.pagat.com/draw/golf.html) and [Bicycle Cards](https://bicyclecards.com/how-to-play/six-card-golf).
|
> **Single Source of Truth** for all game rules, variants, and house rules.
|
||||||
|
> This document is the canonical reference - all implementations must match these specifications.
|
||||||
|
|
||||||
|
## Document Structure
|
||||||
|
|
||||||
|
This document follows a **vertical documentation structure**:
|
||||||
|
1. **Rules** - Human-readable game rules
|
||||||
|
2. **Implementation** - Code references (file:line)
|
||||||
|
3. **Tests** - Verification test references
|
||||||
|
4. **Edge Cases** - Documented edge case behaviors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 1: Core Game Rules
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@ -12,48 +25,141 @@ Golf is a card game where players try to achieve the **lowest score** over multi
|
|||||||
- **Deck:** Standard 52-card deck (optionally with 2 Jokers)
|
- **Deck:** Standard 52-card deck (optionally with 2 Jokers)
|
||||||
- **Multiple decks:** For 5+ players, use 2 decks
|
- **Multiple decks:** For 5+ players, use 2 decks
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Deck creation | `game.py:89-104` |
|
||||||
|
| Multi-deck support | `game.py:92-103` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Standard 52 cards | `test_game.py:501-504` |
|
||||||
|
| Joker deck 54 cards | `test_game.py:506-509` |
|
||||||
|
| Multi-deck | `test_game.py:516-519` |
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Dealer shuffles and deals **6 cards face-down** to each player
|
1. Dealer shuffles and deals **6 cards face-down** to each player
|
||||||
2. Players arrange cards in a **2 row × 3 column grid**:
|
2. Players arrange cards in a **2 row x 3 column grid**:
|
||||||
```
|
```
|
||||||
[0] [1] [2] ← Top row
|
[0] [1] [2] <- Top row
|
||||||
[3] [4] [5] ← Bottom row
|
[3] [4] [5] <- Bottom row
|
||||||
```
|
```
|
||||||
3. Remaining cards form the **draw pile** (face-down)
|
3. Remaining cards form the **draw pile** (face-down)
|
||||||
4. Top card of draw pile is flipped to start the **discard pile**
|
4. Top card of draw pile is flipped to start the **discard pile**
|
||||||
5. Each player flips **2 of their cards** face-up (standard rules)
|
5. Each player flips **2 of their cards** face-up (configurable: 0, 1, or 2)
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Deal 6 cards | `game.py:304-311` |
|
||||||
|
| Start discard pile | `game.py:313-317` |
|
||||||
|
| Initial flip phase | `game.py:326-352` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Initial flip 2 cards | `test_game.py:454-469` |
|
||||||
|
| Initial flip 0 skips phase | `test_game.py:471-478` |
|
||||||
|
| Game starts after all flip | `test_game.py:480-491` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Card Values
|
## Card Values
|
||||||
|
|
||||||
| Card | Points |
|
| Card | Points | Notes |
|
||||||
|------|--------|
|
|------|--------|-------|
|
||||||
| Ace | 1 |
|
| Ace | 1 | Low card |
|
||||||
| 2 | **-2** (negative!) |
|
| **2** | **-2** | Negative! Best non-special card |
|
||||||
| 3-10 | Face value |
|
| 3-10 | Face value | 3=3, 4=4, ..., 10=10 |
|
||||||
| Jack | 10 |
|
| Jack | 10 | Face card |
|
||||||
| Queen | 10 |
|
| Queen | 10 | Face card |
|
||||||
| King | **0** |
|
| **King** | **0** | Zero points |
|
||||||
| Joker | -2 |
|
| **Joker** | **-2** | Negative (requires `use_jokers` option) |
|
||||||
|
|
||||||
|
### Card Value Quality Tiers
|
||||||
|
|
||||||
|
| Tier | Cards | Strategy |
|
||||||
|
|------|-------|----------|
|
||||||
|
| **Excellent** | Joker (-2), 2 (-2) | Always keep, never pair |
|
||||||
|
| **Good** | King (0) | Safe, good for pairing |
|
||||||
|
| **Decent** | Ace (1) | Low risk |
|
||||||
|
| **Neutral** | 3, 4, 5 | Acceptable |
|
||||||
|
| **Bad** | 6, 7 | Replace when possible |
|
||||||
|
| **Terrible** | 8, 9, 10, J, Q | High priority to replace |
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| DEFAULT_CARD_VALUES | `constants.py:3-18` |
|
||||||
|
| RANK_VALUES derivation | `game.py:40-41` |
|
||||||
|
| get_card_value() | `game.py:44-67` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Ace worth 1 | `test_game.py:29-30` |
|
||||||
|
| Two worth -2 | `test_game.py:32-33` |
|
||||||
|
| 3-10 face value | `test_game.py:35-43` |
|
||||||
|
| Jack worth 10 | `test_game.py:45-46` |
|
||||||
|
| Queen worth 10 | `test_game.py:48-49` |
|
||||||
|
| King worth 0 | `test_game.py:51-52` |
|
||||||
|
| Joker worth -2 | `test_game.py:54-55` |
|
||||||
|
| Card.value() method | `test_game.py:57-63` |
|
||||||
|
| Rank quality classification | `test_analyzer.py:47-74` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Column Pairing
|
## Column Pairing
|
||||||
|
|
||||||
**Critical rule:** If both cards in a column have the **same rank**, that column scores **0 points** regardless of the individual card values.
|
**Critical Rule:** If both cards in a column have the **same rank**, that column scores **0 points** regardless of the individual card values.
|
||||||
|
|
||||||
Example:
|
### Column Positions
|
||||||
|
```
|
||||||
|
Column 0: positions (0, 3)
|
||||||
|
Column 1: positions (1, 4)
|
||||||
|
Column 2: positions (2, 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Matched column:**
|
||||||
```
|
```
|
||||||
[K] [5] [7] K-K pair = 0
|
[K] [5] [7] K-K pair = 0
|
||||||
[K] [3] [9] 5+3 = 8, 7+9 = 16
|
[K] [3] [9] 5+3 = 8, 7+9 = 16
|
||||||
Total: 0 + 8 + 16 = 24
|
Total: 0 + 8 + 16 = 24
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Paired 2s score 0 (not -4). The pair cancels out, it doesn't double the negative.
|
**All columns matched:**
|
||||||
|
```
|
||||||
|
[A] [5] [K] All paired = 0 total
|
||||||
|
[A] [5] [K]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edge Case: Paired Negative Cards
|
||||||
|
|
||||||
|
> **IMPORTANT:** Paired 2s score **0**, not -4. The pair **cancels** the value, it doesn't **double** it.
|
||||||
|
|
||||||
|
This is a common source of bugs. When two 2s are paired:
|
||||||
|
- Individual values: -2 + -2 = -4
|
||||||
|
- **Paired value: 0** (pair rule overrides)
|
||||||
|
|
||||||
|
The same applies to paired Jokers (standard rules) - they score 0, not -4.
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Column pair detection | `game.py:158-178` |
|
||||||
|
| Pair cancels to 0 | `game.py:174-175` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Matching column scores 0 | `test_game.py:83-91` |
|
||||||
|
| All columns matched = 0 | `test_game.py:93-98` |
|
||||||
|
| No columns matched = sum | `test_game.py:100-106` |
|
||||||
|
| **Paired 2s = 0 (not -4)** | `test_game.py:108-115` |
|
||||||
|
| Unpaired negatives keep value | `test_game.py:117-124` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Turn Structure
|
## Turn Structure
|
||||||
|
|
||||||
On your turn:
|
|
||||||
|
|
||||||
### 1. Draw Phase
|
### 1. Draw Phase
|
||||||
|
|
||||||
Choose ONE:
|
Choose ONE:
|
||||||
- Draw the **top card from the draw pile** (face-down deck)
|
- Draw the **top card from the draw pile** (face-down deck)
|
||||||
- Take the **top card from the discard pile** (face-up)
|
- Take the **top card from the discard pile** (face-up)
|
||||||
@ -62,7 +168,7 @@ Choose ONE:
|
|||||||
|
|
||||||
**If you drew from the DECK:**
|
**If you drew from the DECK:**
|
||||||
- **Swap:** Replace any card in your grid (old card goes to discard face-up)
|
- **Swap:** Replace any card in your grid (old card goes to discard face-up)
|
||||||
- **Discard:** Put the drawn card on the discard pile and flip one face-down card
|
- **Discard:** Put the drawn card on the discard pile (optionally flip a face-down card)
|
||||||
|
|
||||||
**If you took from the DISCARD PILE:**
|
**If you took from the DISCARD PILE:**
|
||||||
- **You MUST swap** - you cannot re-discard the same card
|
- **You MUST swap** - you cannot re-discard the same card
|
||||||
@ -74,32 +180,80 @@ Choose ONE:
|
|||||||
- You **cannot look** at a face-down card before deciding to replace it
|
- You **cannot look** at a face-down card before deciding to replace it
|
||||||
- When swapping a face-down card, reveal it only as it goes to discard
|
- When swapping a face-down card, reveal it only as it goes to discard
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Draw from deck/discard | `game.py:354-384` |
|
||||||
|
| Swap card | `game.py:409-426` |
|
||||||
|
| Cannot re-discard from discard | `game.py:428-433`, `game.py:443-445` |
|
||||||
|
| Discard from deck draw | `game.py:435-460` |
|
||||||
|
| Flip after discard | `game.py:462-476` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Can draw from deck | `test_game.py:200-205` |
|
||||||
|
| Can draw from discard | `test_game.py:207-214` |
|
||||||
|
| Can discard deck draw | `test_game.py:216-221` |
|
||||||
|
| **Cannot discard discard draw** | `test_game.py:223-228` |
|
||||||
|
| Must swap discard draw | `test_game.py:230-238` |
|
||||||
|
| Swap makes card face-up | `test_game.py:240-247` |
|
||||||
|
| Cannot peek before swap | `test_game.py:249-256` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Round End
|
## Round End
|
||||||
|
|
||||||
### Triggering the Final Turn
|
### Triggering the Final Turn
|
||||||
|
|
||||||
When any player has **all 6 cards face-up**, the round enters "final turn" phase.
|
When any player has **all 6 cards face-up**, the round enters "final turn" phase.
|
||||||
|
|
||||||
### Final Turn Phase
|
### Final Turn Phase
|
||||||
|
|
||||||
- Each **other player** gets exactly **one more turn**
|
- Each **other player** gets exactly **one more turn**
|
||||||
- The player who triggered final turn does NOT get another turn
|
- The player who triggered final turn does NOT get another turn
|
||||||
- After all players have had their final turn, the round ends
|
- After all players have had their final turn, the round ends
|
||||||
|
|
||||||
### Scoring
|
### Scoring
|
||||||
|
|
||||||
1. All remaining face-down cards are revealed
|
1. All remaining face-down cards are revealed
|
||||||
2. Calculate each player's score (with column pairing)
|
2. Calculate each player's score (with column pairing)
|
||||||
3. Add round score to total score
|
3. Add round score to total score
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Check all face-up | `game.py:478-483` |
|
||||||
|
| Final turn phase | `game.py:488-502` |
|
||||||
|
| End round scoring | `game.py:504-555` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Revealing all triggers final turn | `test_game.py:327-341` |
|
||||||
|
| Other players get final turn | `test_game.py:343-358` |
|
||||||
|
| Finisher doesn't get extra turn | `test_game.py:360-373` |
|
||||||
|
| All cards revealed at round end | `test_game.py:375-388` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Winning
|
## Winning
|
||||||
|
|
||||||
- Standard game: **9 rounds** ("9 holes")
|
- Standard game: **9 rounds** ("9 holes")
|
||||||
- Player with the **lowest total score** wins
|
- Player with the **lowest total score** wins
|
||||||
- Optionally play 18 rounds for a longer game
|
- Optionally play 18 rounds for a longer game
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Multi-round tracking | `game.py:557-567` |
|
||||||
|
| Total score accumulation | `game.py:548-549` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Next round resets hands | `test_game.py:398-418` |
|
||||||
|
| Scores accumulate across rounds | `test_game.py:420-444` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# House Rules (Optional)
|
# Part 2: House Rules
|
||||||
|
|
||||||
Our implementation supports these optional rule variations:
|
Our implementation supports these optional rule variations. All are **disabled by default**.
|
||||||
|
|
||||||
## Standard Options
|
## Standard Options
|
||||||
|
|
||||||
@ -110,78 +264,641 @@ Our implementation supports these optional rule variations:
|
|||||||
| `knock_penalty` | +10 if you go out but don't have lowest score | Off |
|
| `knock_penalty` | +10 if you go out but don't have lowest score | Off |
|
||||||
| `use_jokers` | Add Jokers to deck (-2 points each) | Off |
|
| `use_jokers` | Add Jokers to deck (-2 points each) | Off |
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| GameOptions dataclass | `game.py:200-222` |
|
||||||
|
|
||||||
## Point Modifiers
|
## Point Modifiers
|
||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect | Standard Value | Modified Value |
|
||||||
|--------|--------|
|
|--------|--------|----------------|----------------|
|
||||||
| `lucky_swing` | Single Joker worth **-5** (instead of two -2 Jokers) |
|
| `lucky_swing` | Single Joker in deck | 2 Jokers @ -2 each | 1 Joker @ **-5** |
|
||||||
| `super_kings` | Kings worth **-2** (instead of 0) |
|
| `super_kings` | Kings are negative | King = 0 | King = **-2** |
|
||||||
| `ten_penny` | 10s worth **1** (instead of 10) |
|
| `ten_penny` | 10s are low | 10 = 10 | 10 = **1** |
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| LUCKY_SWING_JOKER_VALUE | `constants.py:23` |
|
||||||
|
| SUPER_KINGS_VALUE | `constants.py:21` |
|
||||||
|
| TEN_PENNY_VALUE | `constants.py:22` |
|
||||||
|
| Value application | `game.py:58-66` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Super Kings -2 | `test_game.py:142-149` |
|
||||||
|
| Ten Penny | `test_game.py:151-158` |
|
||||||
|
| Lucky Swing Joker -5 | `test_game.py:160-173` |
|
||||||
|
| Lucky Swing single joker | `test_game.py:511-514` |
|
||||||
|
|
||||||
## Bonuses & Penalties
|
## Bonuses & Penalties
|
||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect | When Applied |
|
||||||
|--------|--------|
|
|--------|--------|--------------|
|
||||||
| `knock_bonus` | First to reveal all cards gets **-5** bonus |
|
| `knock_bonus` | First to reveal all cards gets **-5** | Round end |
|
||||||
| `underdog_bonus` | Lowest scorer each round gets **-3** |
|
| `underdog_bonus` | Lowest scorer each round gets **-3** | Round end |
|
||||||
| `tied_shame` | Tying another player's score = **+5** penalty to both |
|
| `tied_shame` | Tying another player's score = **+5** penalty to both | Round end |
|
||||||
| `blackjack` | Exact score of 21 becomes **0** |
|
| `blackjack` | Exact score of 21 becomes **0** | Round end |
|
||||||
|
| `wolfpack` | 2 pairs of Jacks = **-5** bonus | Scoring |
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Blackjack 21->0 | `game.py:513-517` |
|
||||||
|
| Knock penalty | `game.py:519-525` |
|
||||||
|
| Knock bonus | `game.py:527-531` |
|
||||||
|
| Underdog bonus | `game.py:533-538` |
|
||||||
|
| Tied shame | `game.py:540-546` |
|
||||||
|
| Wolfpack | `game.py:180-182` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Blackjack 21 becomes 0 | `test_game.py:175-183` |
|
||||||
|
| House rules integration | `test_house_rules.py` (full file) |
|
||||||
|
|
||||||
## Special Rules
|
## Special Rules
|
||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (spot the pair!) |
|
| `eagle_eye` | Jokers worth **+2 unpaired**, **-4 paired** (reward spotting pairs) |
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Eagle eye unpaired value | `game.py:60-61` |
|
||||||
|
| Eagle eye paired value | `game.py:169-173` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Game Theory Notes
|
# Part 3: AI Decision Making
|
||||||
|
|
||||||
## Expected Turn Count
|
## AI Profiles
|
||||||
|
|
||||||
With standard rules (2 initial flips):
|
8 distinct AI personalities with different play styles:
|
||||||
- Start: 2 face-up, 4 face-down
|
|
||||||
- Each turn reveals 1 card (swap or discard+flip)
|
|
||||||
- **Minimum turns to go out:** 4
|
|
||||||
- **Typical range:** 4-8 turns per player per round
|
|
||||||
|
|
||||||
## Strategic Considerations
|
| Name | Style | Swap Threshold | Pair Hope | Aggression |
|
||||||
|
|------|-------|----------------|-----------|------------|
|
||||||
|
| Sofia | Calculated & Patient | 4 | 0.2 | 0.2 |
|
||||||
|
| Maya | Aggressive Closer | 6 | 0.4 | 0.85 |
|
||||||
|
| Priya | Pair Hunter | 7 | 0.8 | 0.5 |
|
||||||
|
| Marcus | Steady Eddie | 5 | 0.35 | 0.4 |
|
||||||
|
| Kenji | Risk Taker | 8 | 0.7 | 0.75 |
|
||||||
|
| Diego | Chaotic Gambler | 6 | 0.5 | 0.6 |
|
||||||
|
| River | Adaptive Strategist | 5 | 0.45 | 0.55 |
|
||||||
|
| Sage | Sneaky Finisher | 5 | 0.3 | 0.9 |
|
||||||
|
|
||||||
### Good Cards (keep these)
|
| Implementation | File |
|
||||||
- **Jokers** (-2 or -5): Best cards in the game
|
|----------------|------|
|
||||||
- **2s** (-2): Second best, but don't pair them!
|
| CPUProfile dataclass | `ai.py:164-182` |
|
||||||
- **Kings** (0): Safe, good for pairing
|
| CPU_PROFILES list | `ai.py:186-253` |
|
||||||
|
|
||||||
|
## Key AI Decision Functions
|
||||||
|
|
||||||
|
### should_take_discard()
|
||||||
|
|
||||||
|
Decides whether to take from discard pile or draw from deck.
|
||||||
|
|
||||||
|
**Logic priority:**
|
||||||
|
1. Always take Jokers (and pair if Eagle Eye)
|
||||||
|
2. Always take Kings
|
||||||
|
3. Take 10s if ten_penny enabled
|
||||||
|
4. Take cards that complete a column pair (**except negative cards**)
|
||||||
|
5. Take low cards based on game phase threshold
|
||||||
|
6. Consider end-game pressure
|
||||||
|
7. Take if we have worse visible cards
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| should_take_discard() | `ai.py:333-412` |
|
||||||
|
| Negative card pair avoidance | `ai.py:365-374` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Maya doesn't take 10 with good hand | `test_maya_bug.py:52-83` |
|
||||||
|
| Unpredictability doesn't take bad cards | `test_maya_bug.py:85-116` |
|
||||||
|
| Pair potential respected | `test_maya_bug.py:289-315` |
|
||||||
|
|
||||||
|
### choose_swap_or_discard()
|
||||||
|
|
||||||
|
Decides whether to swap the drawn card into hand or discard it.
|
||||||
|
|
||||||
|
**Logic priority:**
|
||||||
|
1. Eagle Eye: Pair Jokers if visible match exists
|
||||||
|
2. Check for column pair opportunity (**except negative cards**)
|
||||||
|
3. Find best swap among BAD face-up cards (positive value)
|
||||||
|
4. Consider Blackjack (21) pursuit
|
||||||
|
5. Swap excellent cards into face-down positions
|
||||||
|
6. Apply profile-based thresholds
|
||||||
|
|
||||||
|
**Critical:** When placing cards into face-down positions, the AI must avoid creating wasteful pairs with visible negative cards.
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| choose_swap_or_discard() | `ai.py:414-536` |
|
||||||
|
| Negative card pair avoidance | `ai.py:441-446` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Don't discard excellent cards | `test_maya_bug.py:179-209` |
|
||||||
|
| Full Maya bug scenario | `test_maya_bug.py:211-254` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 4: Edge Cases & Known Issues
|
||||||
|
|
||||||
|
## Edge Case: Pairing Negative Cards
|
||||||
|
|
||||||
|
**Problem:** Pairing 2s or Jokers wastes their negative value.
|
||||||
|
- Unpaired 2: contributes -2 to score
|
||||||
|
- Paired 2s: contribute 0 to score (lost 2 points!)
|
||||||
|
|
||||||
|
**AI Safeguards:**
|
||||||
|
1. `should_take_discard()`: Only considers pairing if `discard_value > 0`
|
||||||
|
2. `choose_swap_or_discard()`: Sets `should_pair = drawn_value > 0`
|
||||||
|
3. `filter_bad_pair_positions()`: Filters out positions that would create wasteful pairs when placing negative cards into face-down positions
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| get_column_partner_position() | `ai.py:163-168` |
|
||||||
|
| filter_bad_pair_positions() | `ai.py:171-213` |
|
||||||
|
| Applied in choose_swap_or_discard | `ai.py:517`, `ai.py:538` |
|
||||||
|
| Applied in forced swap | `ai.py:711-713` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Paired 2s = 0 (game logic) | `test_game.py:108-115` |
|
||||||
|
| AI avoids pairing logic | `ai.py:365-374`, `ai.py:441-446` |
|
||||||
|
| Filter with visible two | `test_maya_bug.py:320-347` |
|
||||||
|
| Filter allows positive pairs | `test_maya_bug.py:349-371` |
|
||||||
|
| Choose swap avoids 2 pairs | `test_maya_bug.py:373-401` |
|
||||||
|
| Forced swap avoids 2 pairs | `test_maya_bug.py:403-425` |
|
||||||
|
| Fallback when all bad | `test_maya_bug.py:427-451` |
|
||||||
|
|
||||||
|
## Edge Case: Forced Swap from Discard
|
||||||
|
|
||||||
|
When drawing from discard pile and `choose_swap_or_discard()` returns `None` (discard), the AI is forced to swap anyway. The fallback picks randomly from face-down positions, or finds the worst face-up card.
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Forced swap fallback | `ai.py:665-686` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Forced swap uses house rules | `test_maya_bug.py:143-177` |
|
||||||
|
| All face-up finds worst | `test_maya_bug.py:260-287` |
|
||||||
|
|
||||||
|
## Edge Case: Deck Exhaustion
|
||||||
|
|
||||||
|
When the deck is empty, the discard pile (except top card) is reshuffled back into the deck.
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| Reshuffle discard pile | `game.py:386-407` |
|
||||||
|
|
||||||
|
## Edge Case: Empty Discard Pile
|
||||||
|
|
||||||
|
Cannot draw from empty discard pile.
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| Empty discard returns None | `test_game.py:558-569` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 5: Test Coverage Summary
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
| File | Tests | Focus |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `test_game.py` | 44 | Core game rules |
|
||||||
|
| `test_house_rules.py` | 10+ | House rule integration |
|
||||||
|
| `test_analyzer.py` | 18 | AI decision evaluation |
|
||||||
|
| `test_maya_bug.py` | 18 | Bug regression & AI edge cases |
|
||||||
|
|
||||||
|
**Total: 83+ tests**
|
||||||
|
|
||||||
|
## Coverage by Category
|
||||||
|
|
||||||
|
| Category | Tests | Files | Status |
|
||||||
|
|----------|-------|-------|--------|
|
||||||
|
| Card Values | 8 | `test_game.py`, `test_analyzer.py` | Complete |
|
||||||
|
| Column Pairing | 5 | `test_game.py` | Complete |
|
||||||
|
| House Rules Scoring | 4 | `test_game.py` | Complete |
|
||||||
|
| Draw/Discard Mechanics | 7 | `test_game.py` | Complete |
|
||||||
|
| Turn Flow | 4 | `test_game.py` | Complete |
|
||||||
|
| Round End | 4 | `test_game.py` | Complete |
|
||||||
|
| Multi-Round | 2 | `test_game.py` | Complete |
|
||||||
|
| Initial Flip | 3 | `test_game.py` | Complete |
|
||||||
|
| Deck Management | 4 | `test_game.py` | Complete |
|
||||||
|
| Edge Cases | 3 | `test_game.py` | Complete |
|
||||||
|
| Take Discard Evaluation | 6 | `test_analyzer.py` | Complete |
|
||||||
|
| Swap Evaluation | 6 | `test_analyzer.py` | Complete |
|
||||||
|
| House Rules Evaluation | 2 | `test_analyzer.py` | Complete |
|
||||||
|
| Maya Bug Regression | 6 | `test_maya_bug.py` | Complete |
|
||||||
|
| AI Edge Cases | 3 | `test_maya_bug.py` | Complete |
|
||||||
|
| Bad Pair Avoidance | 5 | `test_maya_bug.py` | Complete |
|
||||||
|
|
||||||
|
## Test Plan: Critical Paths
|
||||||
|
|
||||||
|
### Game State Transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
WAITING -> INITIAL_FLIP -> PLAYING -> FINAL_TURN -> ROUND_OVER -> GAME_OVER
|
||||||
|
| ^
|
||||||
|
v (initial_flips=0) |
|
||||||
|
+-------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
| Transition | Trigger | Tests |
|
||||||
|
|------------|---------|-------|
|
||||||
|
| WAITING -> INITIAL_FLIP | start_game() | `test_game.py:454-469` |
|
||||||
|
| WAITING -> PLAYING | start_game(initial_flips=0) | `test_game.py:471-478` |
|
||||||
|
| INITIAL_FLIP -> PLAYING | All players flip | `test_game.py:480-491` |
|
||||||
|
| PLAYING -> FINAL_TURN | Player all face-up | `test_game.py:327-341` |
|
||||||
|
| FINAL_TURN -> ROUND_OVER | All final turns done | `test_game.py:343-358` |
|
||||||
|
| ROUND_OVER -> PLAYING | start_next_round() | `test_game.py:398-418` |
|
||||||
|
| ROUND_OVER -> GAME_OVER | Final round complete | `test_game.py:420-444` |
|
||||||
|
|
||||||
|
### AI Decision Tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Draw Phase:
|
||||||
|
├── should_take_discard() returns True
|
||||||
|
│ └── Draw from discard pile
|
||||||
|
│ └── MUST swap (can_discard_drawn=False)
|
||||||
|
└── should_take_discard() returns False
|
||||||
|
└── Draw from deck
|
||||||
|
├── choose_swap_or_discard() returns position
|
||||||
|
│ └── Swap at position
|
||||||
|
└── choose_swap_or_discard() returns None
|
||||||
|
└── Discard drawn card
|
||||||
|
└── flip_on_discard? -> choose_flip_after_discard()
|
||||||
|
```
|
||||||
|
|
||||||
|
| Decision Point | Tests |
|
||||||
|
|----------------|-------|
|
||||||
|
| Take Joker/King from discard | `test_analyzer.py:96-114` |
|
||||||
|
| Don't take bad cards | `test_maya_bug.py:52-116` |
|
||||||
|
| Swap excellent cards | `test_maya_bug.py:179-209` |
|
||||||
|
| Avoid pairing negatives | `test_maya_bug.py:320-451` |
|
||||||
|
| Forced swap from discard | `test_maya_bug.py:143-177`, `test_maya_bug.py:403-425` |
|
||||||
|
|
||||||
|
### Scoring Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Expected | Test |
|
||||||
|
|----------|----------|------|
|
||||||
|
| Paired 2s | 0 (not -4) | `test_game.py:108-115` |
|
||||||
|
| Paired Jokers (standard) | 0 | Implicit |
|
||||||
|
| Paired Jokers (eagle_eye) | -4 | `game.py:169-173` |
|
||||||
|
| Unpaired negative cards | -2 each | `test_game.py:117-124` |
|
||||||
|
| All columns matched | 0 total | `test_game.py:93-98` |
|
||||||
|
| Blackjack (21) | 0 | `test_game.py:175-183` |
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
cd server && python -m pytest -v
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
python -m pytest test_game.py -v
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
python -m pytest test_game.py::TestCardValues -v
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
python -m pytest --cov=. --cov-report=html
|
||||||
|
|
||||||
|
# Run tests matching pattern
|
||||||
|
python -m pytest -k "pair" -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Quality Checklist
|
||||||
|
|
||||||
|
- [x] All card values verified against RULES.md
|
||||||
|
- [x] Column pairing logic tested (including negatives)
|
||||||
|
- [x] House rules tested individually
|
||||||
|
- [x] Draw/discard constraints enforced
|
||||||
|
- [x] Turn flow and player validation
|
||||||
|
- [x] Round end and final turn logic
|
||||||
|
- [x] Multi-round score accumulation
|
||||||
|
- [x] AI decision quality evaluation
|
||||||
|
- [x] Bug regression tests for Maya bug
|
||||||
|
- [x] AI avoids wasteful negative card pairs
|
||||||
|
|
||||||
|
## Disadvantageous Moves (AI Quality Metrics)
|
||||||
|
|
||||||
|
### Definition: "Dumb Moves"
|
||||||
|
|
||||||
|
Moves that are objectively suboptimal and should occur at **minimal background noise level** (< 1% of opportunities).
|
||||||
|
|
||||||
|
| Move Type | Severity | Expected Prevalence | Test Coverage |
|
||||||
|
|-----------|----------|---------------------|---------------|
|
||||||
|
| **Discarding Joker/2** | Blunder | 0% | `test_maya_bug.py:179-209` |
|
||||||
|
| **Discarding King** | Mistake | 0% | `test_analyzer.py:183-192` |
|
||||||
|
| **Taking 10/J/Q without pair** | Blunder | 0% | `test_maya_bug.py:52-116` |
|
||||||
|
| **Pairing negative cards** | Mistake | 0% | `test_maya_bug.py:373-401` |
|
||||||
|
| **Swapping good card for bad** | Mistake | 0% | `test_analyzer.py:219-237` |
|
||||||
|
|
||||||
|
### Definition: "Questionable Moves"
|
||||||
|
|
||||||
|
Moves that may be suboptimal but have legitimate strategic reasons. Should be < 5% of opportunities.
|
||||||
|
|
||||||
|
| Move Type | When Acceptable | Monitoring |
|
||||||
|
|-----------|-----------------|------------|
|
||||||
|
| Not taking low card (3-5) | Pair hunting, early game | Profile-based |
|
||||||
|
| Discarding medium card (4-6) | Full hand, pair potential | Context check |
|
||||||
|
| Going out with high score | Pressure, knock_bonus | Threshold based |
|
||||||
|
|
||||||
|
### AI Quality Assertions
|
||||||
|
|
||||||
|
These assertions should pass when running extended simulations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In test suite or simulation
|
||||||
|
def test_ai_quality_metrics():
|
||||||
|
"""Run N games and verify dumb moves are at noise level."""
|
||||||
|
stats = run_simulation(games=1000)
|
||||||
|
|
||||||
|
# ZERO tolerance blunders
|
||||||
|
assert stats.discarded_jokers == 0
|
||||||
|
assert stats.discarded_twos == 0
|
||||||
|
assert stats.took_bad_card_without_pair == 0
|
||||||
|
assert stats.paired_negative_cards == 0
|
||||||
|
|
||||||
|
# Near-zero tolerance mistakes
|
||||||
|
assert stats.discarded_kings < stats.total_turns * 0.001 # < 0.1%
|
||||||
|
assert stats.swapped_good_for_bad < stats.total_turns * 0.001
|
||||||
|
|
||||||
|
# Acceptable variance
|
||||||
|
assert stats.questionable_moves < stats.total_turns * 0.05 # < 5%
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tracking Implementation
|
||||||
|
|
||||||
|
Decision quality should be logged for analysis:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `decision_type` | take_discard, swap, discard, flip |
|
||||||
|
| `decision_quality` | OPTIMAL, GOOD, QUESTIONABLE, MISTAKE, BLUNDER |
|
||||||
|
| `expected_value` | EV calculation for the decision |
|
||||||
|
| `profile_name` | AI personality that made decision |
|
||||||
|
| `game_phase` | early, mid, late |
|
||||||
|
|
||||||
|
See `game_analyzer.py` for decision evaluation logic.
|
||||||
|
|
||||||
|
## Recommended Additional Tests
|
||||||
|
|
||||||
|
| Area | Description | Priority |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| AI Quality Metrics | Simulation-based dumb move detection | **Critical** |
|
||||||
|
| WebSocket | Integration tests for real-time communication | High |
|
||||||
|
| Concurrent games | Multiple simultaneous rooms | Medium |
|
||||||
|
| Deck exhaustion | Reshuffle when deck empty | Medium |
|
||||||
|
| All house rule combos | Interaction between rules | Medium |
|
||||||
|
| AI personality variance | Verify distinct behaviors | Low |
|
||||||
|
| Performance | Load testing with many players | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 6: Strategic Notes
|
||||||
|
|
||||||
|
## Card Priority (for AI and Players)
|
||||||
|
|
||||||
|
### Always Keep
|
||||||
|
- **Jokers** (-2 or -5): Best cards in game
|
||||||
|
- **2s** (-2): Second best, but **don't pair them!**
|
||||||
|
|
||||||
|
### Keep When Possible
|
||||||
|
- **Kings** (0): Safe, excellent for pairing
|
||||||
- **Aces** (1): Low risk
|
- **Aces** (1): Low risk
|
||||||
|
|
||||||
### Bad Cards (replace these)
|
### Replace When Possible
|
||||||
- **10, J, Q** (10 points): Worst cards
|
- **6, 7** (6-7 points): Moderate priority
|
||||||
- **8, 9** (8-9 points): High priority to replace
|
- **8, 9** (8-9 points): High priority
|
||||||
|
- **10, J, Q** (10 points): Highest priority
|
||||||
|
|
||||||
|
## Pairing Strategy
|
||||||
|
|
||||||
### Pairing Strategy
|
|
||||||
- Pairing is powerful - column score goes to 0
|
- Pairing is powerful - column score goes to 0
|
||||||
- **Don't pair negative cards** - you lose the negative benefit
|
- **Never pair negative cards** (2s, Jokers) - you lose the negative benefit
|
||||||
- Target pairs with mid-value cards (3-7) for maximum gain
|
- Target pairs with mid-value cards (3-7) for maximum gain
|
||||||
|
- High-value pairs (10, J, Q) are valuable (+20 point swing)
|
||||||
|
|
||||||
### When to Go Out
|
## When to Go Out
|
||||||
- Go out with **score ≤ 10** when confident you're lowest
|
|
||||||
|
- Go out with **score <= 10** when confident you're lowest
|
||||||
- Consider opponent visible cards before going out early
|
- Consider opponent visible cards before going out early
|
||||||
- With `knock_penalty`, be careful - +10 hurts if you're wrong
|
- With `knock_penalty`, be careful - +10 hurts if you're wrong
|
||||||
|
- With `knock_bonus`, more incentive to finish first
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Test Coverage
|
# Part 7: Configuration
|
||||||
|
|
||||||
The game engine has comprehensive test coverage in `test_game.py`:
|
## Configuration Files
|
||||||
|
|
||||||
- **Card Values:** All 13 ranks verified
|
| File | Purpose |
|
||||||
- **Column Pairing:** Matching, non-matching, negative card edge cases
|
|------|---------|
|
||||||
- **House Rules:** All scoring modifiers tested
|
| `pyproject.toml` | Project metadata, dependencies, tool config |
|
||||||
- **Draw/Discard:** Deck draws, discard draws, must-swap rule
|
| `server/config.py` | Centralized configuration loader |
|
||||||
- **Turn Flow:** Turn advancement, wrap-around, player validation
|
| `server/constants.py` | Card values and game constants |
|
||||||
- **Round End:** Final turn triggering, one-more-turn logic
|
| `.env.example` | Environment variable documentation |
|
||||||
- **Multi-Round:** Score accumulation, hand reset
|
| `.env` | Local environment overrides (not committed) |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configuration precedence (highest to lowest):
|
||||||
|
1. Environment variables
|
||||||
|
2. `.env` file
|
||||||
|
3. Default values in code
|
||||||
|
|
||||||
|
### Server Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `HOST` | `0.0.0.0` | Host to bind to |
|
||||||
|
| `PORT` | `8000` | Port to listen on |
|
||||||
|
| `DEBUG` | `false` | Enable debug mode |
|
||||||
|
| `LOG_LEVEL` | `INFO` | Logging level |
|
||||||
|
| `DATABASE_URL` | `sqlite:///games.db` | Database connection |
|
||||||
|
|
||||||
|
### Room Settings
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MAX_PLAYERS_PER_ROOM` | `6` | Max players per game |
|
||||||
|
| `ROOM_TIMEOUT_MINUTES` | `60` | Inactive room cleanup |
|
||||||
|
| `ROOM_CODE_LENGTH` | `4` | Room code length |
|
||||||
|
|
||||||
|
### Game Defaults
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DEFAULT_ROUNDS` | `9` | Rounds per game |
|
||||||
|
| `DEFAULT_INITIAL_FLIPS` | `2` | Cards to flip at start |
|
||||||
|
| `DEFAULT_USE_JOKERS` | `false` | Enable jokers |
|
||||||
|
| `DEFAULT_FLIP_ON_DISCARD` | `false` | Flip after discard |
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SECRET_KEY` | (empty) | Secret key for sessions |
|
||||||
|
| `INVITE_ONLY` | `false` | Require invitation to register |
|
||||||
|
| `ADMIN_EMAILS` | (empty) | Comma-separated admin emails |
|
||||||
|
|
||||||
|
## Running the Server
|
||||||
|
|
||||||
Run tests with:
|
|
||||||
```bash
|
```bash
|
||||||
pytest test_game.py -v
|
# Development (with auto-reload)
|
||||||
|
DEBUG=true python server/main.py
|
||||||
|
|
||||||
|
# Production
|
||||||
|
PORT=8080 LOG_LEVEL=WARNING python server/main.py
|
||||||
|
|
||||||
|
# With .env file
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env as needed
|
||||||
|
python server/main.py
|
||||||
|
|
||||||
|
# Using uvicorn directly
|
||||||
|
uvicorn server.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 8: Authentication
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The authentication system supports:
|
||||||
|
- User accounts stored in SQLite (`users` table)
|
||||||
|
- Admin accounts that can manage other users
|
||||||
|
- Invite codes (or room codes) for registration
|
||||||
|
- Session-based authentication with bearer tokens
|
||||||
|
|
||||||
|
## First-Time Setup
|
||||||
|
|
||||||
|
When the server starts with no admin accounts:
|
||||||
|
1. A default `admin` account is created (or accounts for each email in `ADMIN_EMAILS`)
|
||||||
|
2. The admin account has **no password** initially
|
||||||
|
3. On first login attempt, use `/api/auth/setup-password` to set the password
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if admin needs setup
|
||||||
|
curl http://localhost:8000/api/auth/check-setup/admin
|
||||||
|
|
||||||
|
# Set admin password (first time only)
|
||||||
|
curl -X POST http://localhost:8000/api/auth/setup-password \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "admin", "new_password": "your-secure-password"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/auth/check-setup/{username}` | GET | Check if user needs password setup |
|
||||||
|
| `/api/auth/setup-password` | POST | Set password (first login only) |
|
||||||
|
| `/api/auth/login` | POST | Login with username/password |
|
||||||
|
| `/api/auth/register` | POST | Register with invite code |
|
||||||
|
| `/api/auth/logout` | POST | Logout current session |
|
||||||
|
|
||||||
|
### Authenticated Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/auth/me` | GET | Get current user info |
|
||||||
|
| `/api/auth/password` | PUT | Change own password |
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/api/admin/users` | GET | List all users |
|
||||||
|
| `/api/admin/users/{id}` | GET | Get user by ID |
|
||||||
|
| `/api/admin/users/{id}` | PUT | Update user |
|
||||||
|
| `/api/admin/users/{id}/password` | PUT | Change user password |
|
||||||
|
| `/api/admin/users/{id}` | DELETE | Deactivate user |
|
||||||
|
| `/api/admin/invites` | POST | Create invite code |
|
||||||
|
| `/api/admin/invites` | GET | List invite codes |
|
||||||
|
| `/api/admin/invites/{code}` | DELETE | Deactivate invite code |
|
||||||
|
|
||||||
|
## Registration Flow
|
||||||
|
|
||||||
|
1. User obtains an invite code (from admin) or a room code (from active game)
|
||||||
|
2. User calls `/api/auth/register` with username, password, and invite code
|
||||||
|
3. If valid, account is created and session token is returned
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register with room code
|
||||||
|
curl -X POST http://localhost:8000/api/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "player1", "password": "pass123", "invite_code": "ABCD"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Header
|
||||||
|
|
||||||
|
After login, include the token in requests:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- SHA-256 with salt
|
||||||
|
role TEXT DEFAULT 'user', -- 'user' or 'admin'
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
last_login TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
invited_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sessions table
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invite codes table
|
||||||
|
CREATE TABLE invite_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
created_by TEXT REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
max_uses INTEGER DEFAULT 1,
|
||||||
|
use_count INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| Implementation | File |
|
||||||
|
|----------------|------|
|
||||||
|
| AuthManager class | `auth.py:87-460` |
|
||||||
|
| User model | `auth.py:27-50` |
|
||||||
|
| Password hashing | `auth.py:159-172` |
|
||||||
|
| Session management | `auth.py:316-360` |
|
||||||
|
|
||||||
|
| Tests | File |
|
||||||
|
|-------|------|
|
||||||
|
| User creation | `test_auth.py:22-60` |
|
||||||
|
| Authentication | `test_auth.py:63-120` |
|
||||||
|
| Invite codes | `test_auth.py:123-175` |
|
||||||
|
| Admin functions | `test_auth.py:178-220` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: Document generated from codebase analysis*
|
||||||
|
*Reference implementations: config.py, constants.py, game.py, ai.py, auth.py*
|
||||||
|
*Test suites: test_game.py, test_house_rules.py, test_analyzer.py, test_maya_bug.py, test_auth.py*
|
||||||
|
|||||||
443
server/ai.py
443
server/ai.py
@ -1,6 +1,7 @@
|
|||||||
"""AI personalities for CPU players in Golf."""
|
"""AI personalities for CPU players in Golf."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -9,6 +10,29 @@ from enum import Enum
|
|||||||
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, get_card_value
|
from game import Card, Player, Game, GamePhase, GameOptions, RANK_VALUES, Rank, get_card_value
|
||||||
|
|
||||||
|
|
||||||
|
# Debug logging configuration
|
||||||
|
# Set AI_DEBUG=1 environment variable to enable detailed AI decision logging
|
||||||
|
AI_DEBUG = os.environ.get("AI_DEBUG", "0") == "1"
|
||||||
|
|
||||||
|
# Create a dedicated logger for AI decisions
|
||||||
|
ai_logger = logging.getLogger("golf.ai")
|
||||||
|
if AI_DEBUG:
|
||||||
|
ai_logger.setLevel(logging.DEBUG)
|
||||||
|
# Add console handler if not already present
|
||||||
|
if not ai_logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter(
|
||||||
|
"%(asctime)s [AI] %(message)s", datefmt="%H:%M:%S"
|
||||||
|
))
|
||||||
|
ai_logger.addHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_log(message: str):
|
||||||
|
"""Log AI decision info when AI_DEBUG is enabled."""
|
||||||
|
if AI_DEBUG:
|
||||||
|
ai_logger.debug(message)
|
||||||
|
|
||||||
|
|
||||||
# Alias for backwards compatibility - use the centralized function from game.py
|
# Alias for backwards compatibility - use the centralized function from game.py
|
||||||
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
def get_ai_card_value(card: Card, options: GameOptions) -> int:
|
||||||
"""Get card value with house rules applied for AI decisions.
|
"""Get card value with house rules applied for AI decisions.
|
||||||
@ -161,6 +185,62 @@ def has_worse_visible_card(player: Player, card_value: int, options: GameOptions
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_column_partner_position(pos: int) -> int:
|
||||||
|
"""Get the column partner position for a given position.
|
||||||
|
|
||||||
|
Column pairs: (0,3), (1,4), (2,5)
|
||||||
|
"""
|
||||||
|
return (pos + 3) % 6 if pos < 3 else pos - 3
|
||||||
|
|
||||||
|
|
||||||
|
def filter_bad_pair_positions(
|
||||||
|
positions: list[int],
|
||||||
|
drawn_card: Card,
|
||||||
|
player: Player,
|
||||||
|
options: GameOptions
|
||||||
|
) -> list[int]:
|
||||||
|
"""Filter out positions that would create wasteful pairs with negative cards.
|
||||||
|
|
||||||
|
When placing a card (especially negative value cards like 2s or Jokers),
|
||||||
|
we should avoid positions where the column partner is a visible card of
|
||||||
|
the same rank - pairing negative cards wastes their value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
positions: List of candidate positions
|
||||||
|
drawn_card: The card we're placing
|
||||||
|
player: The player's hand
|
||||||
|
options: Game options for house rules
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list excluding bad pair positions. If all positions are bad,
|
||||||
|
returns the original list (we have to place somewhere).
|
||||||
|
"""
|
||||||
|
drawn_value = get_ai_card_value(drawn_card, options)
|
||||||
|
|
||||||
|
# Only filter if the drawn card has negative value (2s, Jokers, super_kings Kings)
|
||||||
|
# Pairing positive cards is fine - it turns their value to 0
|
||||||
|
if drawn_value >= 0:
|
||||||
|
return positions
|
||||||
|
|
||||||
|
# Exception: Eagle Eye makes pairing Jokers GOOD (-4 instead of 0)
|
||||||
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||||
|
return positions
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for pos in positions:
|
||||||
|
partner_pos = get_column_partner_position(pos)
|
||||||
|
partner = player.cards[partner_pos]
|
||||||
|
|
||||||
|
# If partner is face-up and same rank, this would create a wasteful pair
|
||||||
|
if partner.face_up and partner.rank == drawn_card.rank:
|
||||||
|
continue # Skip this position
|
||||||
|
|
||||||
|
filtered.append(pos)
|
||||||
|
|
||||||
|
# If all positions were filtered out, return original (must place somewhere)
|
||||||
|
return filtered if filtered else positions
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CPUProfile:
|
class CPUProfile:
|
||||||
"""Pre-defined CPU player profile with personality traits."""
|
"""Pre-defined CPU player profile with personality traits."""
|
||||||
@ -340,6 +420,40 @@ class GolfAI:
|
|||||||
options = game.options
|
options = game.options
|
||||||
discard_value = get_ai_card_value(discard_card, options)
|
discard_value = get_ai_card_value(discard_card, options)
|
||||||
|
|
||||||
|
ai_log(f"--- {profile.name} considering discard: {discard_card.rank.value}{discard_card.suit.value} (value={discard_value}) ---")
|
||||||
|
|
||||||
|
# SAFEGUARD: If we have only 1 face-down card, taking from discard
|
||||||
|
# forces us to swap and go out. Check if that would be acceptable.
|
||||||
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||||
|
if len(face_down) == 1:
|
||||||
|
# Calculate projected score if we swap into the last face-down position
|
||||||
|
projected_score = 0
|
||||||
|
for i, c in enumerate(player.cards):
|
||||||
|
if i == face_down[0]:
|
||||||
|
projected_score += discard_value
|
||||||
|
elif c.face_up:
|
||||||
|
projected_score += get_ai_card_value(c, options)
|
||||||
|
|
||||||
|
# Apply column pair cancellation
|
||||||
|
for col in range(3):
|
||||||
|
top_idx, bot_idx = col, col + 3
|
||||||
|
top_card = discard_card if top_idx == face_down[0] else player.cards[top_idx]
|
||||||
|
bot_card = discard_card if bot_idx == face_down[0] else player.cards[bot_idx]
|
||||||
|
if top_card.rank == bot_card.rank:
|
||||||
|
top_val = discard_value if top_idx == face_down[0] else get_ai_card_value(player.cards[top_idx], options)
|
||||||
|
bot_val = discard_value if bot_idx == face_down[0] else get_ai_card_value(player.cards[bot_idx], options)
|
||||||
|
projected_score -= (top_val + bot_val)
|
||||||
|
|
||||||
|
# Don't take if score would be terrible
|
||||||
|
max_acceptable = 18 if profile.aggression > 0.6 else (16 if profile.aggression > 0.3 else 14)
|
||||||
|
ai_log(f" Go-out check: projected={projected_score}, max_acceptable={max_acceptable}")
|
||||||
|
if projected_score > max_acceptable:
|
||||||
|
# Exception: still take if it's an excellent card (Joker, 2, King, Ace)
|
||||||
|
# and we have a visible bad card to replace instead
|
||||||
|
if discard_value >= 0 and discard_card.rank not in (Rank.ACE, Rank.TWO, Rank.KING, Rank.JOKER):
|
||||||
|
ai_log(f" >> REJECT: would force go-out with {projected_score} pts")
|
||||||
|
return False # Don't take - would force bad go-out
|
||||||
|
|
||||||
# Unpredictable players occasionally make random choice
|
# Unpredictable players occasionally make random choice
|
||||||
# BUT only for reasonable cards (value <= 5) - never randomly take bad cards
|
# BUT only for reasonable cards (value <= 5) - never randomly take bad cards
|
||||||
if random.random() < profile.unpredictability:
|
if random.random() < profile.unpredictability:
|
||||||
@ -352,14 +466,18 @@ class GolfAI:
|
|||||||
if options.eagle_eye:
|
if options.eagle_eye:
|
||||||
for card in player.cards:
|
for card in player.cards:
|
||||||
if card.face_up and card.rank == Rank.JOKER:
|
if card.face_up and card.rank == Rank.JOKER:
|
||||||
|
ai_log(f" >> TAKE: Joker for Eagle Eye pair")
|
||||||
return True
|
return True
|
||||||
|
ai_log(f" >> TAKE: Joker (always take)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if discard_card.rank == Rank.KING:
|
if discard_card.rank == Rank.KING:
|
||||||
|
ai_log(f" >> TAKE: King (always take)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Auto-take 10s when ten_penny enabled (they're worth 1)
|
# Auto-take 10s when ten_penny enabled (they're worth 1)
|
||||||
if discard_card.rank == Rank.TEN and options.ten_penny:
|
if discard_card.rank == Rank.TEN and options.ten_penny:
|
||||||
|
ai_log(f" >> TAKE: 10 (ten_penny rule)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Take card if it could make a column pair (but NOT for negative value cards)
|
# Take card if it could make a column pair (but NOT for negative value cards)
|
||||||
@ -371,6 +489,7 @@ class GolfAI:
|
|||||||
|
|
||||||
# Direct rank match
|
# Direct rank match
|
||||||
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
|
if card.face_up and card.rank == discard_card.rank and not pair_card.face_up:
|
||||||
|
ai_log(f" >> TAKE: can pair with visible {card.rank.value} at pos {i}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Take low cards (using house rule adjusted values)
|
# Take low cards (using house rule adjusted values)
|
||||||
@ -379,6 +498,7 @@ class GolfAI:
|
|||||||
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
|
base_threshold = {'early': 2, 'mid': 3, 'late': 4}.get(phase, 2)
|
||||||
|
|
||||||
if discard_value <= base_threshold:
|
if discard_value <= base_threshold:
|
||||||
|
ai_log(f" >> TAKE: low card (value {discard_value} <= {base_threshold} threshold for {phase} game)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Calculate end-game pressure from opponents close to going out
|
# Calculate end-game pressure from opponents close to going out
|
||||||
@ -395,6 +515,7 @@ class GolfAI:
|
|||||||
# Only take if we have hidden cards that could be worse
|
# Only take if we have hidden cards that could be worse
|
||||||
my_hidden = sum(1 for c in player.cards if not c.face_up)
|
my_hidden = sum(1 for c in player.cards if not c.face_up)
|
||||||
if my_hidden > 0:
|
if my_hidden > 0:
|
||||||
|
ai_log(f" >> TAKE: pressure={pressure:.2f}, threshold={pressure_threshold}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if we have cards worse than the discard
|
# Check if we have cards worse than the discard
|
||||||
@ -407,133 +528,281 @@ class GolfAI:
|
|||||||
# Sanity check: only take if we actually have something worse to replace
|
# Sanity check: only take if we actually have something worse to replace
|
||||||
# This prevents taking a bad card when all visible cards are better
|
# This prevents taking a bad card when all visible cards are better
|
||||||
if has_worse_visible_card(player, discard_value, options):
|
if has_worse_visible_card(player, discard_value, options):
|
||||||
|
ai_log(f" >> TAKE: have worse visible card ({worst_visible})")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
ai_log(f" >> PASS: drawing from deck instead")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_swap_score(
|
||||||
|
pos: int,
|
||||||
|
drawn_card: Card,
|
||||||
|
drawn_value: int,
|
||||||
|
player: Player,
|
||||||
|
options: GameOptions,
|
||||||
|
game: Game,
|
||||||
|
profile: CPUProfile
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate a score for swapping into a specific position.
|
||||||
|
Higher score = better swap. Weighs all incentives:
|
||||||
|
- Pair bonus (highest priority for positive cards)
|
||||||
|
- Point gain from replacement
|
||||||
|
- Reveal bonus for hidden cards
|
||||||
|
- Go-out safety check
|
||||||
|
|
||||||
|
Personality traits affect weights:
|
||||||
|
- pair_hope: higher = values pairing more, lower = prefers spreading
|
||||||
|
- aggression: higher = more willing to go out, take risks
|
||||||
|
- swap_threshold: affects how picky about card values
|
||||||
|
"""
|
||||||
|
current_card = player.cards[pos]
|
||||||
|
partner_pos = get_column_partner_position(pos)
|
||||||
|
partner_card = player.cards[partner_pos]
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
# Personality-based weight modifiers
|
||||||
|
# pair_hope: 0.0-1.0, affects how much we value pairing vs spreading
|
||||||
|
pair_weight = 1.0 + profile.pair_hope # Range: 1.0 to 2.0
|
||||||
|
spread_weight = 2.0 - profile.pair_hope # Range: 1.0 to 2.0 (inverse)
|
||||||
|
|
||||||
|
# 1. PAIR BONUS - Creating a pair
|
||||||
|
# pair_hope affects how much we value this
|
||||||
|
if partner_card.face_up and partner_card.rank == drawn_card.rank:
|
||||||
|
partner_value = get_ai_card_value(partner_card, options)
|
||||||
|
|
||||||
|
if drawn_value >= 0:
|
||||||
|
# Good pair! Both cards cancel to 0
|
||||||
|
pair_bonus = drawn_value + partner_value
|
||||||
|
score += pair_bonus * pair_weight # Pair hunters value this more
|
||||||
|
else:
|
||||||
|
# Pairing negative cards - usually bad
|
||||||
|
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
||||||
|
score += 8 * pair_weight # Eagle Eye Joker pairs
|
||||||
|
else:
|
||||||
|
# Penalty, but pair hunters might still do it
|
||||||
|
penalty = abs(drawn_value) * 2 * (2.0 - profile.pair_hope)
|
||||||
|
score -= penalty
|
||||||
|
|
||||||
|
# 1b. SPREAD BONUS - Not pairing good cards (spreading them out)
|
||||||
|
# Players with low pair_hope prefer spreading aces/2s across columns
|
||||||
|
if not partner_card.face_up or partner_card.rank != drawn_card.rank:
|
||||||
|
if drawn_value <= 1: # Excellent cards (K, 2, A, Joker)
|
||||||
|
# Small bonus for spreading - scales with spread preference
|
||||||
|
score += spread_weight * 0.5
|
||||||
|
|
||||||
|
# 2. POINT GAIN - Direct value improvement
|
||||||
|
if current_card.face_up:
|
||||||
|
current_value = get_ai_card_value(current_card, options)
|
||||||
|
point_gain = current_value - drawn_value
|
||||||
|
score += point_gain
|
||||||
|
else:
|
||||||
|
# Hidden card - expected value ~4.5
|
||||||
|
expected_hidden = 4.5
|
||||||
|
point_gain = expected_hidden - drawn_value
|
||||||
|
# Conservative players (low swap_threshold) discount uncertain gains more
|
||||||
|
discount = 0.5 + (profile.swap_threshold / 16) # Range: 0.5 to 1.0
|
||||||
|
score += point_gain * discount
|
||||||
|
|
||||||
|
# 3. REVEAL BONUS - Value of revealing hidden cards
|
||||||
|
# More aggressive players want to reveal faster to go out
|
||||||
|
if not current_card.face_up:
|
||||||
|
hidden_count = sum(1 for c in player.cards if not c.face_up)
|
||||||
|
reveal_bonus = min(hidden_count, 4)
|
||||||
|
|
||||||
|
# Aggressive players get bigger reveal bonus (want to go out faster)
|
||||||
|
aggression_multiplier = 0.8 + profile.aggression * 0.4 # Range: 0.8 to 1.2
|
||||||
|
|
||||||
|
# Scale by card quality
|
||||||
|
if drawn_value <= 0: # Excellent
|
||||||
|
score += reveal_bonus * 1.2 * aggression_multiplier
|
||||||
|
elif drawn_value == 1: # Great
|
||||||
|
score += reveal_bonus * 1.0 * aggression_multiplier
|
||||||
|
elif drawn_value <= 4: # Good
|
||||||
|
score += reveal_bonus * 0.6 * aggression_multiplier
|
||||||
|
elif drawn_value <= 6: # Medium
|
||||||
|
score += reveal_bonus * 0.3 * aggression_multiplier
|
||||||
|
# Bad cards: no reveal bonus
|
||||||
|
|
||||||
|
# 4. FUTURE PAIR POTENTIAL
|
||||||
|
# Pair hunters value positions where both cards are hidden
|
||||||
|
if not current_card.face_up and not partner_card.face_up:
|
||||||
|
pair_viability = get_pair_viability(drawn_card.rank, game)
|
||||||
|
score += pair_viability * pair_weight * 0.5
|
||||||
|
|
||||||
|
# 5. GO-OUT SAFETY - Penalty for going out with bad score
|
||||||
|
face_down_positions = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||||
|
if len(face_down_positions) == 1 and pos == face_down_positions[0]:
|
||||||
|
projected_score = drawn_value
|
||||||
|
for i, c in enumerate(player.cards):
|
||||||
|
if i != pos and c.face_up:
|
||||||
|
projected_score += get_ai_card_value(c, options)
|
||||||
|
|
||||||
|
# Apply pair cancellation
|
||||||
|
for col in range(3):
|
||||||
|
top_idx, bot_idx = col, col + 3
|
||||||
|
top_card = drawn_card if top_idx == pos else player.cards[top_idx]
|
||||||
|
bot_card = drawn_card if bot_idx == pos else player.cards[bot_idx]
|
||||||
|
if top_card.rank == bot_card.rank:
|
||||||
|
top_val = drawn_value if top_idx == pos else get_ai_card_value(player.cards[top_idx], options)
|
||||||
|
bot_val = drawn_value if bot_idx == pos else get_ai_card_value(player.cards[bot_idx], options)
|
||||||
|
projected_score -= (top_val + bot_val)
|
||||||
|
|
||||||
|
# Aggressive players accept higher scores when going out
|
||||||
|
max_acceptable = 12 + int(profile.aggression * 8) # Range: 12 to 20
|
||||||
|
if projected_score > max_acceptable:
|
||||||
|
score -= 100
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def choose_swap_or_discard(drawn_card: Card, player: Player,
|
def choose_swap_or_discard(drawn_card: Card, player: Player,
|
||||||
profile: CPUProfile, game: Game) -> Optional[int]:
|
profile: CPUProfile, game: Game) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
Decide whether to swap the drawn card or discard.
|
Decide whether to swap the drawn card or discard.
|
||||||
Returns position to swap with, or None to discard.
|
Returns position to swap with, or None to discard.
|
||||||
|
|
||||||
|
Uses a unified scoring system that weighs all incentives:
|
||||||
|
- Pair creation (best for positive cards, bad for negative)
|
||||||
|
- Point gain from replacement
|
||||||
|
- Revealing hidden cards (catching up, information)
|
||||||
|
- Safety (don't go out with terrible score)
|
||||||
"""
|
"""
|
||||||
options = game.options
|
options = game.options
|
||||||
drawn_value = get_ai_card_value(drawn_card, options)
|
drawn_value = get_ai_card_value(drawn_card, options)
|
||||||
|
|
||||||
# Unpredictable players occasionally make surprising play
|
ai_log(f"=== {profile.name} deciding: drew {drawn_card.rank.value}{drawn_card.suit.value} (value={drawn_value}) ===")
|
||||||
# BUT never discard excellent cards (Jokers, 2s, Kings, Aces)
|
ai_log(f" Personality: pair_hope={profile.pair_hope:.2f}, aggression={profile.aggression:.2f}, "
|
||||||
|
f"swap_threshold={profile.swap_threshold}, unpredictability={profile.unpredictability:.2f}")
|
||||||
|
|
||||||
|
# Log current hand state
|
||||||
|
hand_str = " ".join(
|
||||||
|
f"[{i}:{c.rank.value if c.face_up else '?'}]" for i, c in enumerate(player.cards)
|
||||||
|
)
|
||||||
|
ai_log(f" Hand: {hand_str}")
|
||||||
|
|
||||||
|
# Unpredictable players occasionally make surprising plays
|
||||||
|
# But never discard excellent cards (Jokers, 2s, Kings, Aces)
|
||||||
if random.random() < profile.unpredictability:
|
if random.random() < profile.unpredictability:
|
||||||
if drawn_value > 1: # Only be unpredictable with non-excellent cards
|
if drawn_value > 1:
|
||||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||||
if face_down and random.random() < 0.5:
|
if face_down and random.random() < 0.5:
|
||||||
return random.choice(face_down)
|
choice = random.choice(face_down)
|
||||||
|
ai_log(f" >> UNPREDICTABLE: randomly chose position {choice}")
|
||||||
|
return choice
|
||||||
|
|
||||||
# Eagle Eye: If drawn card is Joker, look for existing visible Joker to pair
|
# Calculate score for each position
|
||||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
position_scores: list[tuple[int, float]] = []
|
||||||
for i, card in enumerate(player.cards):
|
for pos in range(6):
|
||||||
if card.face_up and card.rank == Rank.JOKER:
|
score = GolfAI.calculate_swap_score(
|
||||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
pos, drawn_card, drawn_value, player, options, game, profile
|
||||||
if not player.cards[pair_pos].face_up:
|
)
|
||||||
return pair_pos
|
position_scores.append((pos, score))
|
||||||
|
|
||||||
# Check for column pair opportunity first
|
# Log all scores
|
||||||
# But DON'T pair negative value cards (2s, Jokers) - keeping them unpaired is better!
|
ai_log(f" Position scores:")
|
||||||
# Exception: Eagle Eye makes pairing Jokers GOOD (doubled negative)
|
for pos, score in position_scores:
|
||||||
should_pair = drawn_value > 0
|
card = player.cards[pos]
|
||||||
if options.eagle_eye and drawn_card.rank == Rank.JOKER:
|
partner_pos = get_column_partner_position(pos)
|
||||||
should_pair = True
|
partner = player.cards[partner_pos]
|
||||||
|
card_str = card.rank.value if card.face_up else "?"
|
||||||
|
partner_str = partner.rank.value if partner.face_up else "?"
|
||||||
|
pair_indicator = " [PAIR]" if partner.face_up and partner.rank == drawn_card.rank else ""
|
||||||
|
reveal_indicator = " [REVEAL]" if not card.face_up else ""
|
||||||
|
ai_log(f" pos {pos} ({card_str}, partner={partner_str}): {score:+.2f}{pair_indicator}{reveal_indicator}")
|
||||||
|
|
||||||
if should_pair:
|
# Filter to positive scores only
|
||||||
for i, card in enumerate(player.cards):
|
positive_scores = [(p, s) for p, s in position_scores if s > 0]
|
||||||
pair_pos = (i + 3) % 6 if i < 3 else i - 3
|
|
||||||
pair_card = player.cards[pair_pos]
|
|
||||||
|
|
||||||
# Direct rank match
|
best_pos: Optional[int] = None
|
||||||
if card.face_up and card.rank == drawn_card.rank and not pair_card.face_up:
|
best_score = 0.0
|
||||||
return pair_pos
|
|
||||||
|
|
||||||
if pair_card.face_up and pair_card.rank == drawn_card.rank and not card.face_up:
|
if positive_scores:
|
||||||
return i
|
# Sort by score descending
|
||||||
|
positive_scores.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
best_pos, best_score = positive_scores[0]
|
||||||
|
|
||||||
# Find best swap among face-up cards that are BAD (positive value)
|
# PERSONALITY TIE-BREAKER: When top options are close, let personality decide
|
||||||
# Don't swap good cards (Kings, 2s, etc.) just for marginal gains -
|
close_threshold = 2.0 # Options within 2 points are "close"
|
||||||
# we want to keep good cards and put new good cards into face-down positions
|
close_options = [(p, s) for p, s in positive_scores if s >= best_score - close_threshold]
|
||||||
best_swap: Optional[int] = None
|
|
||||||
best_gain = 0
|
|
||||||
|
|
||||||
for i, card in enumerate(player.cards):
|
if len(close_options) > 1:
|
||||||
if card.face_up:
|
ai_log(f" TIE-BREAKER: {len(close_options)} options within {close_threshold} pts of best ({best_score:.2f})")
|
||||||
card_value = get_ai_card_value(card, options)
|
original_best = best_pos
|
||||||
# Only consider replacing cards that are actually bad (positive value)
|
|
||||||
if card_value > 0:
|
|
||||||
gain = card_value - drawn_value
|
|
||||||
if gain > best_gain:
|
|
||||||
best_gain = gain
|
|
||||||
best_swap = i
|
|
||||||
|
|
||||||
# Swap if we gain points (conservative players need more gain)
|
# Multiple close options - personality decides
|
||||||
min_gain = 2 if profile.swap_threshold <= 4 else 1
|
# Categorize each option
|
||||||
if best_gain >= min_gain:
|
for pos, score in close_options:
|
||||||
return best_swap
|
partner_pos = get_column_partner_position(pos)
|
||||||
|
partner_card = player.cards[partner_pos]
|
||||||
|
is_pair_move = partner_card.face_up and partner_card.rank == drawn_card.rank
|
||||||
|
is_reveal_move = not player.cards[pos].face_up
|
||||||
|
|
||||||
# Blackjack: Check if any swap would result in exactly 21
|
# Pair hunters prefer pair moves
|
||||||
if options.blackjack:
|
if is_pair_move and profile.pair_hope > 0.6:
|
||||||
|
ai_log(f" >> PAIR_HOPE ({profile.pair_hope:.2f}): chose pair move at pos {pos}")
|
||||||
|
best_pos = pos
|
||||||
|
break
|
||||||
|
# Aggressive players prefer reveal moves (to go out faster)
|
||||||
|
if is_reveal_move and profile.aggression > 0.7:
|
||||||
|
ai_log(f" >> AGGRESSION ({profile.aggression:.2f}): chose reveal move at pos {pos}")
|
||||||
|
best_pos = pos
|
||||||
|
break
|
||||||
|
# Conservative players prefer safe visible card replacements
|
||||||
|
if not is_reveal_move and profile.swap_threshold <= 4:
|
||||||
|
ai_log(f" >> CONSERVATIVE (threshold={profile.swap_threshold}): chose safe move at pos {pos}")
|
||||||
|
best_pos = pos
|
||||||
|
break
|
||||||
|
|
||||||
|
# If still tied, add small random factor based on unpredictability
|
||||||
|
if profile.unpredictability > 0.1 and random.random() < profile.unpredictability:
|
||||||
|
best_pos = random.choice([p for p, s in close_options])
|
||||||
|
ai_log(f" >> RANDOM (unpredictability={profile.unpredictability:.2f}): chose pos {best_pos}")
|
||||||
|
|
||||||
|
if best_pos != original_best:
|
||||||
|
ai_log(f" Tie-breaker changed choice: {original_best} -> {best_pos}")
|
||||||
|
|
||||||
|
# Blackjack special case: chase exactly 21
|
||||||
|
if options.blackjack and best_pos is None:
|
||||||
current_score = player.calculate_score()
|
current_score = player.calculate_score()
|
||||||
if current_score >= 15: # Only chase 21 from high scores
|
if current_score >= 15:
|
||||||
for i, card in enumerate(player.cards):
|
for i, card in enumerate(player.cards):
|
||||||
if card.face_up:
|
if card.face_up:
|
||||||
# Calculate score if we swap here
|
|
||||||
potential_change = drawn_value - get_ai_card_value(card, options)
|
potential_change = drawn_value - get_ai_card_value(card, options)
|
||||||
potential_score = current_score + potential_change
|
if current_score + potential_change == 21:
|
||||||
if potential_score == 21:
|
|
||||||
# Aggressive players more likely to chase 21
|
|
||||||
if random.random() < profile.aggression:
|
if random.random() < profile.aggression:
|
||||||
|
ai_log(f" >> BLACKJACK: chasing 21 at position {i}")
|
||||||
return i
|
return i
|
||||||
|
|
||||||
# Consider swapping with face-down cards for very good cards (negative or zero value)
|
# Pair hunters might hold medium cards hoping for matches
|
||||||
# 10s (ten_penny) become "excellent" cards worth keeping
|
if best_pos is not None and not player.cards[best_pos].face_up:
|
||||||
is_excellent = (drawn_value <= 0 or
|
if drawn_value >= 5: # Only hold out for medium/high cards
|
||||||
drawn_card.rank == Rank.ACE or
|
|
||||||
(options.ten_penny and drawn_card.rank == Rank.TEN))
|
|
||||||
|
|
||||||
# Calculate pair viability and game phase for smarter decisions
|
|
||||||
pair_viability = get_pair_viability(drawn_card.rank, game)
|
pair_viability = get_pair_viability(drawn_card.rank, game)
|
||||||
phase = get_game_phase(game)
|
phase = get_game_phase(game)
|
||||||
pressure = get_end_game_pressure(player, game)
|
pressure = get_end_game_pressure(player, game)
|
||||||
|
|
||||||
if is_excellent:
|
|
||||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
|
||||||
if face_down:
|
|
||||||
# Pair hunters might hold out hoping for matches
|
|
||||||
# BUT: reduce hope if pair is unlikely or late game pressure
|
|
||||||
effective_hope = profile.pair_hope * pair_viability
|
effective_hope = profile.pair_hope * pair_viability
|
||||||
if phase == 'late' or pressure > 0.5:
|
if phase == 'late' or pressure > 0.5:
|
||||||
effective_hope *= 0.3 # Much less willing to gamble late game
|
effective_hope *= 0.3
|
||||||
if effective_hope > 0.6 and random.random() < effective_hope:
|
|
||||||
return None
|
|
||||||
return random.choice(face_down)
|
|
||||||
|
|
||||||
# For medium cards, swap threshold based on profile
|
ai_log(f" Hold-for-pair check: value={drawn_value}, viability={pair_viability:.2f}, "
|
||||||
# Late game: be more willing to swap in medium cards
|
f"phase={phase}, effective_hope={effective_hope:.2f}")
|
||||||
effective_threshold = profile.swap_threshold
|
|
||||||
if phase == 'late' or pressure > 0.5:
|
|
||||||
effective_threshold += 2 # Accept higher value cards under pressure
|
|
||||||
|
|
||||||
if drawn_value <= effective_threshold:
|
if effective_hope > 0.5 and random.random() < effective_hope:
|
||||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
ai_log(f" >> HOLDING: discarding {drawn_card.rank.value} hoping for future pair")
|
||||||
if face_down:
|
return None # Discard and hope for pair later
|
||||||
# Pair hunters hold high cards hoping for matches
|
|
||||||
# BUT: check if pairing is actually viable
|
|
||||||
effective_hope = profile.pair_hope * pair_viability
|
|
||||||
if phase == 'late' or pressure > 0.5:
|
|
||||||
effective_hope *= 0.3 # Don't gamble late game
|
|
||||||
if effective_hope > 0.5 and drawn_value >= 6:
|
|
||||||
if random.random() < effective_hope:
|
|
||||||
return None
|
|
||||||
return random.choice(face_down)
|
|
||||||
|
|
||||||
return None
|
# Log final decision
|
||||||
|
if best_pos is not None:
|
||||||
|
target_card = player.cards[best_pos]
|
||||||
|
target_str = target_card.rank.value if target_card.face_up else "hidden"
|
||||||
|
ai_log(f" DECISION: SWAP into position {best_pos} (replacing {target_str}) [score={best_score:.2f}]")
|
||||||
|
else:
|
||||||
|
ai_log(f" DECISION: DISCARD {drawn_card.rank.value} (no good swap options)")
|
||||||
|
|
||||||
|
return best_pos
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def choose_flip_after_discard(player: Player, profile: CPUProfile) -> int:
|
def choose_flip_after_discard(player: Player, profile: CPUProfile) -> int:
|
||||||
@ -665,7 +934,9 @@ async def process_cpu_turn(
|
|||||||
if swap_pos is None and game.drawn_from_discard:
|
if swap_pos is None and game.drawn_from_discard:
|
||||||
face_down = [i for i, c in enumerate(cpu_player.cards) if not c.face_up]
|
face_down = [i for i, c in enumerate(cpu_player.cards) if not c.face_up]
|
||||||
if face_down:
|
if face_down:
|
||||||
swap_pos = random.choice(face_down)
|
# Filter out positions that would create bad pairs with negative cards
|
||||||
|
safe_positions = filter_bad_pair_positions(face_down, drawn, cpu_player, game.options)
|
||||||
|
swap_pos = random.choice(safe_positions)
|
||||||
else:
|
else:
|
||||||
# All cards are face up - find worst card to replace (using house rules)
|
# All cards are face up - find worst card to replace (using house rules)
|
||||||
worst_pos = 0
|
worst_pos = 0
|
||||||
|
|||||||
602
server/auth.py
Normal file
602
server/auth.py
Normal file
@ -0,0 +1,602 @@
|
|||||||
|
"""
|
||||||
|
Authentication and user management for Golf game.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- User accounts stored in SQLite
|
||||||
|
- Admin accounts can manage other users
|
||||||
|
- Invite codes (room codes) allow new user registration
|
||||||
|
- Session-based authentication via tokens
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(Enum):
|
||||||
|
"""User roles for access control."""
|
||||||
|
USER = "user"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
"""User account."""
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
email: Optional[str]
|
||||||
|
password_hash: str
|
||||||
|
role: UserRole
|
||||||
|
created_at: datetime
|
||||||
|
last_login: Optional[datetime]
|
||||||
|
is_active: bool
|
||||||
|
invited_by: Optional[str] # Username of who invited them
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return self.role == UserRole.ADMIN
|
||||||
|
|
||||||
|
def to_dict(self, include_sensitive: bool = False) -> dict:
|
||||||
|
"""Convert to dictionary for API responses."""
|
||||||
|
data = {
|
||||||
|
"id": self.id,
|
||||||
|
"username": self.username,
|
||||||
|
"email": self.email,
|
||||||
|
"role": self.role.value,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"last_login": self.last_login.isoformat() if self.last_login else None,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"invited_by": self.invited_by,
|
||||||
|
}
|
||||||
|
if include_sensitive:
|
||||||
|
data["password_hash"] = self.password_hash
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Session:
|
||||||
|
"""User session."""
|
||||||
|
token: str
|
||||||
|
user_id: str
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return datetime.now() > self.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InviteCode:
|
||||||
|
"""Invite code for user registration."""
|
||||||
|
code: str
|
||||||
|
created_by: str # User ID who created the invite
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: Optional[datetime]
|
||||||
|
max_uses: int
|
||||||
|
use_count: int
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
if not self.is_active:
|
||||||
|
return False
|
||||||
|
if self.expires_at and datetime.now() > self.expires_at:
|
||||||
|
return False
|
||||||
|
if self.max_uses > 0 and self.use_count >= self.max_uses:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthManager:
|
||||||
|
"""Manages user authentication and authorization."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "games.db"):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self._init_db()
|
||||||
|
self._ensure_admin()
|
||||||
|
|
||||||
|
def _init_db(self):
|
||||||
|
"""Initialize auth database schema."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.executescript("""
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT DEFAULT 'user',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
invited_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sessions table
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Invite codes table
|
||||||
|
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
created_by TEXT REFERENCES users(id),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
max_uses INTEGER DEFAULT 1,
|
||||||
|
use_count INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_codes_active ON invite_codes(is_active);
|
||||||
|
""")
|
||||||
|
|
||||||
|
def _ensure_admin(self):
|
||||||
|
"""Ensure at least one admin account exists (without password - must be set on first login)."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE role = ?",
|
||||||
|
(UserRole.ADMIN.value,)
|
||||||
|
)
|
||||||
|
admin_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if admin_count == 0:
|
||||||
|
# Check if admin emails are configured
|
||||||
|
if config.ADMIN_EMAILS:
|
||||||
|
# Create admin accounts for configured emails (no password yet)
|
||||||
|
for email in config.ADMIN_EMAILS:
|
||||||
|
username = email.split("@")[0]
|
||||||
|
self._create_user_without_password(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
print(f"Created admin account: {username} - password must be set on first login")
|
||||||
|
else:
|
||||||
|
# Create default admin if no admins exist (no password yet)
|
||||||
|
self._create_user_without_password(
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
print("Created default admin account - password must be set on first login")
|
||||||
|
print("Set ADMIN_EMAILS in .env to configure admin accounts.")
|
||||||
|
|
||||||
|
def _create_user_without_password(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
role: UserRole = UserRole.USER,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Create a user without a password (for first-time setup)."""
|
||||||
|
user_id = secrets.token_hex(16)
|
||||||
|
# Empty password_hash indicates password needs to be set
|
||||||
|
password_hash = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, username, email, password_hash, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, username, email, password_hash, role.value),
|
||||||
|
)
|
||||||
|
return user_id
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def needs_password_setup(self, username: str) -> bool:
|
||||||
|
"""Check if user needs to set up their password (first login)."""
|
||||||
|
user = self.get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
return user.password_hash == ""
|
||||||
|
|
||||||
|
def setup_password(self, username: str, new_password: str) -> Optional[User]:
|
||||||
|
"""Set password for first-time setup. Only works if password is not yet set."""
|
||||||
|
user = self.get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if user.password_hash != "":
|
||||||
|
return None # Password already set
|
||||||
|
|
||||||
|
password_hash = self._hash_password(new_password)
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ?, last_login = ? WHERE id = ?",
|
||||||
|
(password_hash, datetime.now(), user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.get_user_by_id(user.id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_password(password: str) -> str:
|
||||||
|
"""Hash a password using SHA-256 with salt."""
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
hash_input = f"{salt}:{password}".encode()
|
||||||
|
password_hash = hashlib.sha256(hash_input).hexdigest()
|
||||||
|
return f"{salt}:{password_hash}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verify_password(password: str, stored_hash: str) -> bool:
|
||||||
|
"""Verify a password against its hash."""
|
||||||
|
try:
|
||||||
|
salt, hash_value = stored_hash.split(":")
|
||||||
|
hash_input = f"{salt}:{password}".encode()
|
||||||
|
computed_hash = hashlib.sha256(hash_input).hexdigest()
|
||||||
|
return secrets.compare_digest(computed_hash, hash_value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
role: UserRole = UserRole.USER,
|
||||||
|
invited_by: Optional[str] = None,
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Create a new user account."""
|
||||||
|
user_id = secrets.token_hex(16)
|
||||||
|
password_hash = self._hash_password(password)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, username, email, password_hash, role, invited_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, username, email, password_hash, role.value, invited_by),
|
||||||
|
)
|
||||||
|
return self.get_user_by_id(user_id)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None # Username or email already exists
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||||
|
"""Get user by ID."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM users WHERE id = ?",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return self._row_to_user(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||||
|
"""Get user by username."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM users WHERE username = ?",
|
||||||
|
(username,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return self._row_to_user(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _row_to_user(self, row: sqlite3.Row) -> User:
|
||||||
|
"""Convert database row to User object."""
|
||||||
|
return User(
|
||||||
|
id=row["id"],
|
||||||
|
username=row["username"],
|
||||||
|
email=row["email"],
|
||||||
|
password_hash=row["password_hash"],
|
||||||
|
role=UserRole(row["role"]),
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else None,
|
||||||
|
last_login=datetime.fromisoformat(row["last_login"]) if row["last_login"] else None,
|
||||||
|
is_active=bool(row["is_active"]),
|
||||||
|
invited_by=row["invited_by"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def authenticate(self, username: str, password: str) -> Optional[User]:
|
||||||
|
"""Authenticate user with username and password."""
|
||||||
|
user = self.get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not user.is_active:
|
||||||
|
return None
|
||||||
|
if not self._verify_password(password, user.password_hash):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update last login
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||||
|
(datetime.now(), user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_session(self, user: User, duration_hours: int = 24) -> Session:
|
||||||
|
"""Create a new session for a user."""
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
created_at = datetime.now()
|
||||||
|
expires_at = created_at + timedelta(hours=duration_hours)
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (token, user_id, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(token, user.id, created_at, expires_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
return Session(
|
||||||
|
token=token,
|
||||||
|
user_id=user.id,
|
||||||
|
created_at=created_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_session(self, token: str) -> Optional[Session]:
|
||||||
|
"""Get session by token."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE token = ?",
|
||||||
|
(token,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
session = Session(
|
||||||
|
token=row["token"],
|
||||||
|
user_id=row["user_id"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
expires_at=datetime.fromisoformat(row["expires_at"]),
|
||||||
|
)
|
||||||
|
if not session.is_expired():
|
||||||
|
return session
|
||||||
|
# Clean up expired session
|
||||||
|
self.invalidate_session(token)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user_from_session(self, token: str) -> Optional[User]:
|
||||||
|
"""Get user from session token."""
|
||||||
|
session = self.get_session(token)
|
||||||
|
if session:
|
||||||
|
return self.get_user_by_id(session.user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invalidate_session(self, token: str):
|
||||||
|
"""Invalidate a session."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
||||||
|
|
||||||
|
def invalidate_user_sessions(self, user_id: str):
|
||||||
|
"""Invalidate all sessions for a user."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Invite Codes
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def create_invite_code(
|
||||||
|
self,
|
||||||
|
created_by: str,
|
||||||
|
max_uses: int = 1,
|
||||||
|
expires_in_days: Optional[int] = 7,
|
||||||
|
) -> InviteCode:
|
||||||
|
"""Create a new invite code."""
|
||||||
|
code = secrets.token_urlsafe(8).upper()[:8] # 8 character code
|
||||||
|
created_at = datetime.now()
|
||||||
|
expires_at = created_at + timedelta(days=expires_in_days) if expires_in_days else None
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invite_codes (code, created_by, created_at, expires_at, max_uses)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(code, created_by, created_at, expires_at, max_uses)
|
||||||
|
)
|
||||||
|
|
||||||
|
return InviteCode(
|
||||||
|
code=code,
|
||||||
|
created_by=created_by,
|
||||||
|
created_at=created_at,
|
||||||
|
expires_at=expires_at,
|
||||||
|
max_uses=max_uses,
|
||||||
|
use_count=0,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_invite_code(self, code: str) -> Optional[InviteCode]:
|
||||||
|
"""Get invite code by code string."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM invite_codes WHERE code = ?",
|
||||||
|
(code.upper(),)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return InviteCode(
|
||||||
|
code=row["code"],
|
||||||
|
created_by=row["created_by"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
|
||||||
|
max_uses=row["max_uses"],
|
||||||
|
use_count=row["use_count"],
|
||||||
|
is_active=bool(row["is_active"]),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def use_invite_code(self, code: str) -> bool:
|
||||||
|
"""Mark an invite code as used. Returns False if invalid."""
|
||||||
|
invite = self.get_invite_code(code)
|
||||||
|
if not invite or not invite.is_valid():
|
||||||
|
return False
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE invite_codes SET use_count = use_count + 1 WHERE code = ?",
|
||||||
|
(code.upper(),)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_room_code_as_invite(self, room_code: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a room code is valid for registration.
|
||||||
|
Room codes from active games act as invite codes.
|
||||||
|
"""
|
||||||
|
# First check if it's an explicit invite code
|
||||||
|
invite = self.get_invite_code(room_code)
|
||||||
|
if invite and invite.is_valid():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if it's an active room code (from room manager)
|
||||||
|
# This will be checked by the caller since we don't have room_manager here
|
||||||
|
return False
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Admin Functions
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def list_users(self, include_inactive: bool = False) -> list[User]:
|
||||||
|
"""List all users (admin function)."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
if include_inactive:
|
||||||
|
cursor = conn.execute("SELECT * FROM users ORDER BY created_at DESC")
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
return [self._row_to_user(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def update_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
role: Optional[UserRole] = None,
|
||||||
|
is_active: Optional[bool] = None,
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Update user details (admin function)."""
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if username is not None:
|
||||||
|
updates.append("username = ?")
|
||||||
|
params.append(username)
|
||||||
|
if email is not None:
|
||||||
|
updates.append("email = ?")
|
||||||
|
params.append(email)
|
||||||
|
if role is not None:
|
||||||
|
updates.append("role = ?")
|
||||||
|
params.append(role.value)
|
||||||
|
if is_active is not None:
|
||||||
|
updates.append("is_active = ?")
|
||||||
|
params.append(is_active)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return self.get_user_by_id(user_id)
|
||||||
|
|
||||||
|
params.append(user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE users SET {', '.join(updates)} WHERE id = ?",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
return self.get_user_by_id(user_id)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def change_password(self, user_id: str, new_password: str) -> bool:
|
||||||
|
"""Change user password."""
|
||||||
|
password_hash = self._hash_password(new_password)
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||||
|
(password_hash, user_id)
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def delete_user(self, user_id: str) -> bool:
|
||||||
|
"""Delete a user (admin function). Actually just deactivates."""
|
||||||
|
# Invalidate all sessions first
|
||||||
|
self.invalidate_user_sessions(user_id)
|
||||||
|
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"UPDATE users SET is_active = 0 WHERE id = ?",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def list_invite_codes(self, created_by: Optional[str] = None) -> list[InviteCode]:
|
||||||
|
"""List invite codes (admin function)."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
if created_by:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM invite_codes WHERE created_by = ? ORDER BY created_at DESC",
|
||||||
|
(created_by,)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM invite_codes ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
InviteCode(
|
||||||
|
code=row["code"],
|
||||||
|
created_by=row["created_by"],
|
||||||
|
created_at=datetime.fromisoformat(row["created_at"]),
|
||||||
|
expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
|
||||||
|
max_uses=row["max_uses"],
|
||||||
|
use_count=row["use_count"],
|
||||||
|
is_active=bool(row["is_active"]),
|
||||||
|
)
|
||||||
|
for row in cursor.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def deactivate_invite_code(self, code: str) -> bool:
|
||||||
|
"""Deactivate an invite code (admin function)."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"UPDATE invite_codes SET is_active = 0 WHERE code = ?",
|
||||||
|
(code.upper(),)
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self):
|
||||||
|
"""Remove expired sessions from database."""
|
||||||
|
with sqlite3.connect(self.db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM sessions WHERE expires_at < ?",
|
||||||
|
(datetime.now(),)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global auth manager instance (lazy initialization)
|
||||||
|
_auth_manager: Optional[AuthManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_manager() -> AuthManager:
|
||||||
|
"""Get or create the global auth manager instance."""
|
||||||
|
global _auth_manager
|
||||||
|
if _auth_manager is None:
|
||||||
|
_auth_manager = AuthManager()
|
||||||
|
return _auth_manager
|
||||||
176
server/config.py
Normal file
176
server/config.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Centralized configuration for Golf game server.
|
||||||
|
|
||||||
|
Configuration is loaded from (in order of precedence):
|
||||||
|
1. Environment variables
|
||||||
|
2. .env file (if exists)
|
||||||
|
3. Default values
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from config import config
|
||||||
|
print(config.PORT)
|
||||||
|
print(config.card_values)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Load .env file if it exists
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
env_path = Path(__file__).parent.parent / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
load_dotenv(env_path)
|
||||||
|
except ImportError:
|
||||||
|
pass # python-dotenv not installed, use env vars only
|
||||||
|
|
||||||
|
|
||||||
|
def get_env(key: str, default: str = "") -> str:
|
||||||
|
"""Get environment variable with default."""
|
||||||
|
return os.environ.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_bool(key: str, default: bool = False) -> bool:
|
||||||
|
"""Get boolean environment variable."""
|
||||||
|
val = os.environ.get(key, "").lower()
|
||||||
|
if val in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if val in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_int(key: str, default: int = 0) -> int:
|
||||||
|
"""Get integer environment variable."""
|
||||||
|
try:
|
||||||
|
return int(os.environ.get(key, str(default)))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CardValues:
|
||||||
|
"""Card point values - the single source of truth."""
|
||||||
|
ACE: int = 1
|
||||||
|
TWO: int = -2
|
||||||
|
THREE: int = 3
|
||||||
|
FOUR: int = 4
|
||||||
|
FIVE: int = 5
|
||||||
|
SIX: int = 6
|
||||||
|
SEVEN: int = 7
|
||||||
|
EIGHT: int = 8
|
||||||
|
NINE: int = 9
|
||||||
|
TEN: int = 10
|
||||||
|
JACK: int = 10
|
||||||
|
QUEEN: int = 10
|
||||||
|
KING: int = 0
|
||||||
|
JOKER: int = -2
|
||||||
|
|
||||||
|
# House rule modifiers
|
||||||
|
SUPER_KINGS: int = -2 # King value when super_kings enabled
|
||||||
|
TEN_PENNY: int = 1 # 10 value when ten_penny enabled
|
||||||
|
LUCKY_SWING_JOKER: int = -5 # Joker value when lucky_swing enabled
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, int]:
|
||||||
|
"""Get card values as dictionary for game use."""
|
||||||
|
return {
|
||||||
|
'A': self.ACE,
|
||||||
|
'2': self.TWO,
|
||||||
|
'3': self.THREE,
|
||||||
|
'4': self.FOUR,
|
||||||
|
'5': self.FIVE,
|
||||||
|
'6': self.SIX,
|
||||||
|
'7': self.SEVEN,
|
||||||
|
'8': self.EIGHT,
|
||||||
|
'9': self.NINE,
|
||||||
|
'10': self.TEN,
|
||||||
|
'J': self.JACK,
|
||||||
|
'Q': self.QUEEN,
|
||||||
|
'K': self.KING,
|
||||||
|
'★': self.JOKER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameDefaults:
|
||||||
|
"""Default game settings."""
|
||||||
|
rounds: int = 9
|
||||||
|
initial_flips: int = 2
|
||||||
|
use_jokers: bool = False
|
||||||
|
flip_on_discard: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServerConfig:
|
||||||
|
"""Server configuration."""
|
||||||
|
HOST: str = "0.0.0.0"
|
||||||
|
PORT: int = 8000
|
||||||
|
DEBUG: bool = False
|
||||||
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = "sqlite:///games.db"
|
||||||
|
|
||||||
|
# Room settings
|
||||||
|
MAX_PLAYERS_PER_ROOM: int = 6
|
||||||
|
ROOM_TIMEOUT_MINUTES: int = 60
|
||||||
|
ROOM_CODE_LENGTH: int = 4
|
||||||
|
|
||||||
|
# Security (for future auth system)
|
||||||
|
SECRET_KEY: str = ""
|
||||||
|
INVITE_ONLY: bool = False
|
||||||
|
ADMIN_EMAILS: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Card values
|
||||||
|
card_values: CardValues = field(default_factory=CardValues)
|
||||||
|
|
||||||
|
# Game defaults
|
||||||
|
game_defaults: GameDefaults = field(default_factory=GameDefaults)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "ServerConfig":
|
||||||
|
"""Load configuration from environment variables."""
|
||||||
|
admin_emails_str = get_env("ADMIN_EMAILS", "")
|
||||||
|
admin_emails = [e.strip() for e in admin_emails_str.split(",") if e.strip()]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
HOST=get_env("HOST", "0.0.0.0"),
|
||||||
|
PORT=get_env_int("PORT", 8000),
|
||||||
|
DEBUG=get_env_bool("DEBUG", False),
|
||||||
|
LOG_LEVEL=get_env("LOG_LEVEL", "INFO"),
|
||||||
|
DATABASE_URL=get_env("DATABASE_URL", "sqlite:///games.db"),
|
||||||
|
MAX_PLAYERS_PER_ROOM=get_env_int("MAX_PLAYERS_PER_ROOM", 6),
|
||||||
|
ROOM_TIMEOUT_MINUTES=get_env_int("ROOM_TIMEOUT_MINUTES", 60),
|
||||||
|
ROOM_CODE_LENGTH=get_env_int("ROOM_CODE_LENGTH", 4),
|
||||||
|
SECRET_KEY=get_env("SECRET_KEY", ""),
|
||||||
|
INVITE_ONLY=get_env_bool("INVITE_ONLY", False),
|
||||||
|
ADMIN_EMAILS=admin_emails,
|
||||||
|
card_values=CardValues(
|
||||||
|
ACE=get_env_int("CARD_ACE", 1),
|
||||||
|
TWO=get_env_int("CARD_TWO", -2),
|
||||||
|
KING=get_env_int("CARD_KING", 0),
|
||||||
|
JOKER=get_env_int("CARD_JOKER", -2),
|
||||||
|
SUPER_KINGS=get_env_int("CARD_SUPER_KINGS", -2),
|
||||||
|
TEN_PENNY=get_env_int("CARD_TEN_PENNY", 1),
|
||||||
|
LUCKY_SWING_JOKER=get_env_int("CARD_LUCKY_SWING_JOKER", -5),
|
||||||
|
),
|
||||||
|
game_defaults=GameDefaults(
|
||||||
|
rounds=get_env_int("DEFAULT_ROUNDS", 9),
|
||||||
|
initial_flips=get_env_int("DEFAULT_INITIAL_FLIPS", 2),
|
||||||
|
use_jokers=get_env_bool("DEFAULT_USE_JOKERS", False),
|
||||||
|
flip_on_discard=get_env_bool("DEFAULT_FLIP_ON_DISCARD", False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global config instance - loaded once at module import
|
||||||
|
config = ServerConfig.from_env()
|
||||||
|
|
||||||
|
|
||||||
|
def reload_config() -> ServerConfig:
|
||||||
|
"""Reload configuration from environment (useful for testing)."""
|
||||||
|
global config
|
||||||
|
config = ServerConfig.from_env()
|
||||||
|
return config
|
||||||
@ -4,6 +4,9 @@ Card value constants for 6-Card Golf.
|
|||||||
This module is the single source of truth for all card point values.
|
This module is the single source of truth for all card point values.
|
||||||
House rule modifications are defined here and applied in game.py.
|
House rule modifications are defined here and applied in game.py.
|
||||||
|
|
||||||
|
Configuration can be customized via environment variables.
|
||||||
|
See config.py and .env.example for details.
|
||||||
|
|
||||||
Standard Golf Scoring:
|
Standard Golf Scoring:
|
||||||
- Ace: 1 point
|
- Ace: 1 point
|
||||||
- Two: -2 points (special - only negative non-joker)
|
- Two: -2 points (special - only negative non-joker)
|
||||||
@ -15,7 +18,26 @@ Standard Golf Scoring:
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
# Base card values (no house rules applied)
|
# Try to load from config (which reads env vars), fall back to hardcoded defaults
|
||||||
|
try:
|
||||||
|
from config import config
|
||||||
|
_use_config = True
|
||||||
|
except ImportError:
|
||||||
|
_use_config = False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Card Values - Single Source of Truth
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if _use_config:
|
||||||
|
# Load from environment-aware config
|
||||||
|
DEFAULT_CARD_VALUES: dict[str, int] = config.card_values.to_dict()
|
||||||
|
SUPER_KINGS_VALUE: int = config.card_values.SUPER_KINGS
|
||||||
|
TEN_PENNY_VALUE: int = config.card_values.TEN_PENNY
|
||||||
|
LUCKY_SWING_JOKER_VALUE: int = config.card_values.LUCKY_SWING_JOKER
|
||||||
|
else:
|
||||||
|
# Hardcoded defaults (fallback)
|
||||||
DEFAULT_CARD_VALUES: dict[str, int] = {
|
DEFAULT_CARD_VALUES: dict[str, int] = {
|
||||||
'A': 1,
|
'A': 1,
|
||||||
'2': -2,
|
'2': -2,
|
||||||
@ -32,13 +54,37 @@ DEFAULT_CARD_VALUES: dict[str, int] = {
|
|||||||
'K': 0,
|
'K': 0,
|
||||||
'★': -2, # Joker (standard mode)
|
'★': -2, # Joker (standard mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- House Rule Value Overrides ---
|
|
||||||
SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0
|
SUPER_KINGS_VALUE: int = -2 # Kings worth -2 instead of 0
|
||||||
TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10
|
TEN_PENNY_VALUE: int = 1 # 10s worth 1 instead of 10
|
||||||
LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
|
LUCKY_SWING_JOKER_VALUE: int = -5 # Single joker worth -5
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Game Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
if _use_config:
|
||||||
|
MAX_PLAYERS = config.MAX_PLAYERS_PER_ROOM
|
||||||
|
ROOM_CODE_LENGTH = config.ROOM_CODE_LENGTH
|
||||||
|
ROOM_TIMEOUT_MINUTES = config.ROOM_TIMEOUT_MINUTES
|
||||||
|
DEFAULT_ROUNDS = config.game_defaults.rounds
|
||||||
|
DEFAULT_INITIAL_FLIPS = config.game_defaults.initial_flips
|
||||||
|
DEFAULT_USE_JOKERS = config.game_defaults.use_jokers
|
||||||
|
DEFAULT_FLIP_ON_DISCARD = config.game_defaults.flip_on_discard
|
||||||
|
else:
|
||||||
|
MAX_PLAYERS = 6
|
||||||
|
ROOM_CODE_LENGTH = 4
|
||||||
|
ROOM_TIMEOUT_MINUTES = 60
|
||||||
|
DEFAULT_ROUNDS = 9
|
||||||
|
DEFAULT_INITIAL_FLIPS = 2
|
||||||
|
DEFAULT_USE_JOKERS = False
|
||||||
|
DEFAULT_FLIP_ON_DISCARD = False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
def get_card_value_for_rank(
|
def get_card_value_for_rank(
|
||||||
rank_str: str,
|
rank_str: str,
|
||||||
options: Optional[dict] = None,
|
options: Optional[dict] = None,
|
||||||
|
|||||||
@ -76,14 +76,11 @@ class GameLogger:
|
|||||||
"use_jokers": options.use_jokers,
|
"use_jokers": options.use_jokers,
|
||||||
"lucky_swing": options.lucky_swing,
|
"lucky_swing": options.lucky_swing,
|
||||||
"super_kings": options.super_kings,
|
"super_kings": options.super_kings,
|
||||||
"lucky_sevens": options.lucky_sevens,
|
|
||||||
"ten_penny": options.ten_penny,
|
"ten_penny": options.ten_penny,
|
||||||
"knock_bonus": options.knock_bonus,
|
"knock_bonus": options.knock_bonus,
|
||||||
"underdog_bonus": options.underdog_bonus,
|
"underdog_bonus": options.underdog_bonus,
|
||||||
"tied_shame": options.tied_shame,
|
"tied_shame": options.tied_shame,
|
||||||
"blackjack": options.blackjack,
|
"blackjack": options.blackjack,
|
||||||
"queens_wild": options.queens_wild,
|
|
||||||
"four_of_a_kind": options.four_of_a_kind,
|
|
||||||
"eagle_eye": options.eagle_eye,
|
"eagle_eye": options.eagle_eye,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
server/games.db
Normal file
BIN
server/games.db
Normal file
Binary file not shown.
427
server/main.py
427
server/main.py
@ -1,18 +1,33 @@
|
|||||||
"""FastAPI WebSocket server for Golf card game."""
|
"""FastAPI WebSocket server for Golf card game."""
|
||||||
|
|
||||||
import uuid
|
import logging
|
||||||
import asyncio
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from config import config
|
||||||
from room import RoomManager, Room
|
from room import RoomManager, Room
|
||||||
from game import GamePhase, GameOptions
|
from game import GamePhase, GameOptions
|
||||||
from ai import GolfAI, process_cpu_turn, get_all_profiles
|
from ai import GolfAI, process_cpu_turn, get_all_profiles
|
||||||
from game_log import get_logger
|
from game_log import get_logger
|
||||||
|
from auth import get_auth_manager, User, UserRole
|
||||||
|
|
||||||
app = FastAPI(title="Golf Card Game")
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Golf Card Game",
|
||||||
|
debug=config.DEBUG,
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
room_manager = RoomManager()
|
room_manager = RoomManager()
|
||||||
|
|
||||||
@ -22,6 +37,374 @@ async def health_check():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Auth Models
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
email: Optional[str] = None
|
||||||
|
invite_code: str # Room code or explicit invite code
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetupPasswordRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRequest(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInviteRequest(BaseModel):
|
||||||
|
max_uses: int = 1
|
||||||
|
expires_in_days: Optional[int] = 7
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Auth Dependencies
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[User]:
|
||||||
|
"""Get current user from Authorization header."""
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Expect "Bearer <token>"
|
||||||
|
parts = authorization.split()
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = parts[1]
|
||||||
|
auth = get_auth_manager()
|
||||||
|
return auth.get_user_from_session(token)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
|
||||||
|
"""Require authenticated user."""
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="Account disabled")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(user: User = Depends(require_user)) -> User:
|
||||||
|
"""Require admin user."""
|
||||||
|
if not user.is_admin():
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Auth Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.post("/api/auth/register")
|
||||||
|
async def register(request: RegisterRequest):
|
||||||
|
"""Register a new user with an invite code."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
# Validate invite code
|
||||||
|
invite_valid = False
|
||||||
|
inviter_username = None
|
||||||
|
|
||||||
|
# Check if it's an explicit invite code
|
||||||
|
invite = auth.get_invite_code(request.invite_code)
|
||||||
|
if invite and invite.is_valid():
|
||||||
|
invite_valid = True
|
||||||
|
inviter = auth.get_user_by_id(invite.created_by)
|
||||||
|
inviter_username = inviter.username if inviter else None
|
||||||
|
|
||||||
|
# Check if it's a valid room code
|
||||||
|
if not invite_valid:
|
||||||
|
room = room_manager.get_room(request.invite_code.upper())
|
||||||
|
if room:
|
||||||
|
invite_valid = True
|
||||||
|
# Room codes are like open invites
|
||||||
|
|
||||||
|
if not invite_valid:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid invite code")
|
||||||
|
|
||||||
|
# Create user
|
||||||
|
user = auth.create_user(
|
||||||
|
username=request.username,
|
||||||
|
password=request.password,
|
||||||
|
email=request.email,
|
||||||
|
invited_by=inviter_username,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Username or email already taken")
|
||||||
|
|
||||||
|
# Mark invite code as used (if it was an explicit invite)
|
||||||
|
if invite:
|
||||||
|
auth.use_invite_code(request.invite_code)
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session = auth.create_session(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user.to_dict(),
|
||||||
|
"token": session.token,
|
||||||
|
"expires_at": session.expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
async def login(request: LoginRequest):
|
||||||
|
"""Login with username and password."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
# Check if user needs password setup (first login)
|
||||||
|
if auth.needs_password_setup(request.username):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=428, # Precondition Required
|
||||||
|
detail="Password setup required. Use /api/auth/setup-password endpoint."
|
||||||
|
)
|
||||||
|
|
||||||
|
user = auth.authenticate(request.username, request.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|
||||||
|
session = auth.create_session(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user.to_dict(),
|
||||||
|
"token": session.token,
|
||||||
|
"expires_at": session.expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/setup-password")
|
||||||
|
async def setup_password(request: SetupPasswordRequest):
|
||||||
|
"""Set password for first-time login (admin accounts created without password)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
# Verify user exists and needs setup
|
||||||
|
if not auth.needs_password_setup(request.username):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Password setup not available for this account"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the password
|
||||||
|
user = auth.setup_password(request.username, request.new_password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Setup failed")
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session = auth.create_session(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user.to_dict(),
|
||||||
|
"token": session.token,
|
||||||
|
"expires_at": session.expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/check-setup/{username}")
|
||||||
|
async def check_setup_needed(username: str):
|
||||||
|
"""Check if a username needs password setup."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
needs_setup = auth.needs_password_setup(username)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"needs_password_setup": needs_setup,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout")
|
||||||
|
async def logout(authorization: Optional[str] = Header(None)):
|
||||||
|
"""Logout current session."""
|
||||||
|
if authorization:
|
||||||
|
parts = authorization.split()
|
||||||
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||||
|
auth = get_auth_manager()
|
||||||
|
auth.invalidate_session(parts[1])
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/me")
|
||||||
|
async def get_me(user: User = Depends(require_user)):
|
||||||
|
"""Get current user info."""
|
||||||
|
return {"user": user.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/auth/password")
|
||||||
|
async def change_own_password(
|
||||||
|
request: ChangePasswordRequest,
|
||||||
|
user: User = Depends(require_user)
|
||||||
|
):
|
||||||
|
"""Change own password."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
auth.change_password(user.id, request.new_password)
|
||||||
|
# Invalidate all other sessions
|
||||||
|
auth.invalidate_user_sessions(user.id)
|
||||||
|
# Create new session
|
||||||
|
session = auth.create_session(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"token": session.token,
|
||||||
|
"expires_at": session.expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Admin Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.get("/api/admin/users")
|
||||||
|
async def list_users(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
admin: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""List all users (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
users = auth.list_users(include_inactive=include_inactive)
|
||||||
|
return {"users": [u.to_dict() for u in users]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/users/{user_id}")
|
||||||
|
async def get_user(user_id: str, admin: User = Depends(require_admin)):
|
||||||
|
"""Get user by ID (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
user = auth.get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return {"user": user.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/admin/users/{user_id}")
|
||||||
|
async def update_user(
|
||||||
|
user_id: str,
|
||||||
|
request: UpdateUserRequest,
|
||||||
|
admin: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Update user (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
# Convert role string to enum if provided
|
||||||
|
role = UserRole(request.role) if request.role else None
|
||||||
|
|
||||||
|
user = auth.update_user(
|
||||||
|
user_id=user_id,
|
||||||
|
username=request.username,
|
||||||
|
email=request.email,
|
||||||
|
role=role,
|
||||||
|
is_active=request.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Update failed (duplicate username/email?)")
|
||||||
|
|
||||||
|
return {"user": user.to_dict()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/admin/users/{user_id}/password")
|
||||||
|
async def admin_change_password(
|
||||||
|
user_id: str,
|
||||||
|
request: ChangePasswordRequest,
|
||||||
|
admin: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Change user password (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
if not auth.change_password(user_id, request.new_password):
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Invalidate all user sessions
|
||||||
|
auth.invalidate_user_sessions(user_id)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/users/{user_id}")
|
||||||
|
async def delete_user(user_id: str, admin: User = Depends(require_admin)):
|
||||||
|
"""Deactivate user (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
# Don't allow deleting yourself
|
||||||
|
if user_id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||||
|
|
||||||
|
if not auth.delete_user(user_id):
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/invites")
|
||||||
|
async def create_invite(
|
||||||
|
request: CreateInviteRequest,
|
||||||
|
admin: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Create an invite code (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
invite = auth.create_invite_code(
|
||||||
|
created_by=admin.id,
|
||||||
|
max_uses=request.max_uses,
|
||||||
|
expires_in_days=request.expires_in_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": invite.code,
|
||||||
|
"max_uses": invite.max_uses,
|
||||||
|
"expires_at": invite.expires_at.isoformat() if invite.expires_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/invites")
|
||||||
|
async def list_invites(admin: User = Depends(require_admin)):
|
||||||
|
"""List all invite codes (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
invites = auth.list_invite_codes()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"invites": [
|
||||||
|
{
|
||||||
|
"code": i.code,
|
||||||
|
"created_by": i.created_by,
|
||||||
|
"created_at": i.created_at.isoformat(),
|
||||||
|
"expires_at": i.expires_at.isoformat() if i.expires_at else None,
|
||||||
|
"max_uses": i.max_uses,
|
||||||
|
"use_count": i.use_count,
|
||||||
|
"is_active": i.is_active,
|
||||||
|
"is_valid": i.is_valid(),
|
||||||
|
}
|
||||||
|
for i in invites
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/invites/{code}")
|
||||||
|
async def deactivate_invite(code: str, admin: User = Depends(require_admin)):
|
||||||
|
"""Deactivate an invite code (admin only)."""
|
||||||
|
auth = get_auth_manager()
|
||||||
|
|
||||||
|
if not auth.deactivate_invite_code(code):
|
||||||
|
raise HTTPException(status_code=404, detail="Invite code not found")
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
@ -485,3 +868,35 @@ if os.path.exists(client_path):
|
|||||||
@app.get("/app.js")
|
@app.get("/app.js")
|
||||||
async def serve_js():
|
async def serve_js():
|
||||||
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
|
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
|
||||||
|
|
||||||
|
@app.get("/card-manager.js")
|
||||||
|
async def serve_card_manager():
|
||||||
|
return FileResponse(os.path.join(client_path, "card-manager.js"), media_type="application/javascript")
|
||||||
|
|
||||||
|
@app.get("/state-differ.js")
|
||||||
|
async def serve_state_differ():
|
||||||
|
return FileResponse(os.path.join(client_path, "state-differ.js"), media_type="application/javascript")
|
||||||
|
|
||||||
|
@app.get("/animation-queue.js")
|
||||||
|
async def serve_animation_queue():
|
||||||
|
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
|
def run():
|
||||||
|
"""Run the server using uvicorn."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
logger.info(f"Starting Golf server on {config.HOST}:{config.PORT}")
|
||||||
|
logger.info(f"Debug mode: {config.DEBUG}")
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"main:app",
|
||||||
|
host=config.HOST,
|
||||||
|
port=config.PORT,
|
||||||
|
reload=config.DEBUG,
|
||||||
|
log_level=config.LOG_LEVEL.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
fastapi==0.109.0
|
fastapi>=0.109.0
|
||||||
uvicorn[standard]==0.27.0
|
uvicorn[standard]>=0.27.0
|
||||||
websockets==12.0
|
websockets>=12.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|||||||
BIN
server/score_distribution.png
Normal file
BIN
server/score_distribution.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@ -20,8 +20,10 @@ from typing import Optional
|
|||||||
from game import Game, Player, GamePhase, GameOptions
|
from game import Game, Player, GamePhase, GameOptions
|
||||||
from ai import (
|
from ai import (
|
||||||
GolfAI, CPUProfile, CPU_PROFILES,
|
GolfAI, CPUProfile, CPU_PROFILES,
|
||||||
get_ai_card_value, has_worse_visible_card
|
get_ai_card_value, has_worse_visible_card,
|
||||||
|
filter_bad_pair_positions, get_column_partner_position
|
||||||
)
|
)
|
||||||
|
from game import Rank
|
||||||
from game_log import GameLogger
|
from game_log import GameLogger
|
||||||
|
|
||||||
|
|
||||||
@ -36,6 +38,15 @@ class SimulationStats:
|
|||||||
self.player_scores: dict[str, list[int]] = {}
|
self.player_scores: dict[str, list[int]] = {}
|
||||||
self.decisions: dict[str, dict] = {} # player -> {action: count}
|
self.decisions: dict[str, dict] = {} # player -> {action: count}
|
||||||
|
|
||||||
|
# Dumb move tracking
|
||||||
|
self.discarded_jokers = 0
|
||||||
|
self.discarded_twos = 0
|
||||||
|
self.discarded_kings = 0
|
||||||
|
self.took_bad_card_without_pair = 0
|
||||||
|
self.paired_negative_cards = 0
|
||||||
|
self.swapped_good_for_bad = 0
|
||||||
|
self.total_opportunities = 0 # Total decision points
|
||||||
|
|
||||||
def record_game(self, game: Game, winner_name: str):
|
def record_game(self, game: Game, winner_name: str):
|
||||||
self.games_played += 1
|
self.games_played += 1
|
||||||
self.total_rounds += game.current_round
|
self.total_rounds += game.current_round
|
||||||
@ -57,6 +68,40 @@ class SimulationStats:
|
|||||||
self.decisions[player_name][action] = 0
|
self.decisions[player_name][action] = 0
|
||||||
self.decisions[player_name][action] += 1
|
self.decisions[player_name][action] += 1
|
||||||
|
|
||||||
|
def record_dumb_move(self, move_type: str):
|
||||||
|
"""Record a dumb move for analysis."""
|
||||||
|
if move_type == "discarded_joker":
|
||||||
|
self.discarded_jokers += 1
|
||||||
|
elif move_type == "discarded_two":
|
||||||
|
self.discarded_twos += 1
|
||||||
|
elif move_type == "discarded_king":
|
||||||
|
self.discarded_kings += 1
|
||||||
|
elif move_type == "took_bad_without_pair":
|
||||||
|
self.took_bad_card_without_pair += 1
|
||||||
|
elif move_type == "paired_negative":
|
||||||
|
self.paired_negative_cards += 1
|
||||||
|
elif move_type == "swapped_good_for_bad":
|
||||||
|
self.swapped_good_for_bad += 1
|
||||||
|
|
||||||
|
def record_opportunity(self):
|
||||||
|
"""Record a decision opportunity for rate calculation."""
|
||||||
|
self.total_opportunities += 1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dumb_move_rate(self) -> float:
|
||||||
|
"""Calculate overall dumb move rate."""
|
||||||
|
total_dumb = (
|
||||||
|
self.discarded_jokers +
|
||||||
|
self.discarded_twos +
|
||||||
|
self.discarded_kings +
|
||||||
|
self.took_bad_card_without_pair +
|
||||||
|
self.paired_negative_cards +
|
||||||
|
self.swapped_good_for_bad
|
||||||
|
)
|
||||||
|
if self.total_opportunities == 0:
|
||||||
|
return 0.0
|
||||||
|
return total_dumb / self.total_opportunities * 100
|
||||||
|
|
||||||
def report(self) -> str:
|
def report(self) -> str:
|
||||||
lines = [
|
lines = [
|
||||||
"=" * 50,
|
"=" * 50,
|
||||||
@ -95,6 +140,21 @@ class SimulationStats:
|
|||||||
pct = count / max(1, total) * 100
|
pct = count / max(1, total) * 100
|
||||||
lines.append(f" {action}: {count} ({pct:.1f}%)")
|
lines.append(f" {action}: {count} ({pct:.1f}%)")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("DUMB MOVE ANALYSIS:")
|
||||||
|
lines.append(f" Total decision opportunities: {self.total_opportunities}")
|
||||||
|
lines.append(f" Dumb move rate: {self.dumb_move_rate:.3f}%")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" Blunders (should be 0):")
|
||||||
|
lines.append(f" Discarded Jokers: {self.discarded_jokers}")
|
||||||
|
lines.append(f" Discarded 2s: {self.discarded_twos}")
|
||||||
|
lines.append(f" Took bad card without pair: {self.took_bad_card_without_pair}")
|
||||||
|
lines.append(f" Paired negative cards: {self.paired_negative_cards}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" Mistakes (should be < 0.1%):")
|
||||||
|
lines.append(f" Discarded Kings: {self.discarded_kings}")
|
||||||
|
lines.append(f" Swapped good for bad: {self.swapped_good_for_bad}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@ -134,6 +194,27 @@ def run_cpu_turn(
|
|||||||
action = "take_discard" if take_discard else "draw_deck"
|
action = "take_discard" if take_discard else "draw_deck"
|
||||||
stats.record_turn(player.name, action)
|
stats.record_turn(player.name, action)
|
||||||
|
|
||||||
|
# Check for dumb move: taking bad card from discard without good reason
|
||||||
|
if take_discard:
|
||||||
|
drawn_val = get_ai_card_value(drawn, game.options)
|
||||||
|
# Bad cards are 8, 9, 10, J, Q (value >= 8)
|
||||||
|
if drawn_val >= 8:
|
||||||
|
# Check if there's pair potential
|
||||||
|
has_pair_potential = False
|
||||||
|
for i, card in enumerate(player.cards):
|
||||||
|
if card.face_up and card.rank == drawn.rank:
|
||||||
|
partner_pos = get_column_partner_position(i)
|
||||||
|
if not player.cards[partner_pos].face_up:
|
||||||
|
has_pair_potential = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if player has a WORSE visible card to replace
|
||||||
|
has_worse_to_replace = has_worse_visible_card(player, drawn_val, game.options)
|
||||||
|
|
||||||
|
# Only flag as dumb if no pair potential AND no worse card to replace
|
||||||
|
if not has_pair_potential and not has_worse_to_replace:
|
||||||
|
stats.record_dumb_move("took_bad_without_pair")
|
||||||
|
|
||||||
# Log draw decision
|
# Log draw decision
|
||||||
if logger and game_id:
|
if logger and game_id:
|
||||||
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
|
reason = f"took {discard_top.rank.value} from discard" if take_discard else "drew from deck"
|
||||||
@ -154,7 +235,9 @@ def run_cpu_turn(
|
|||||||
if swap_pos is None and game.drawn_from_discard:
|
if swap_pos is None and game.drawn_from_discard:
|
||||||
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
face_down = [i for i, c in enumerate(player.cards) if not c.face_up]
|
||||||
if face_down:
|
if face_down:
|
||||||
swap_pos = random.choice(face_down)
|
# Use filter to avoid bad pairs with negative cards
|
||||||
|
safe_positions = filter_bad_pair_positions(face_down, drawn, player, game.options)
|
||||||
|
swap_pos = random.choice(safe_positions)
|
||||||
else:
|
else:
|
||||||
# Find worst card using house rules
|
# Find worst card using house rules
|
||||||
worst_pos = 0
|
worst_pos = 0
|
||||||
@ -166,8 +249,27 @@ def run_cpu_turn(
|
|||||||
worst_pos = i
|
worst_pos = i
|
||||||
swap_pos = worst_pos
|
swap_pos = worst_pos
|
||||||
|
|
||||||
|
# Record this as a decision opportunity for dumb move rate calculation
|
||||||
|
stats.record_opportunity()
|
||||||
|
|
||||||
if swap_pos is not None:
|
if swap_pos is not None:
|
||||||
old_card = player.cards[swap_pos]
|
old_card = player.cards[swap_pos]
|
||||||
|
|
||||||
|
# Check for dumb moves: swapping good card for bad
|
||||||
|
drawn_val = get_ai_card_value(drawn, game.options)
|
||||||
|
old_val = get_ai_card_value(old_card, game.options)
|
||||||
|
if old_card.face_up and old_val < drawn_val and old_val <= 1:
|
||||||
|
stats.record_dumb_move("swapped_good_for_bad")
|
||||||
|
|
||||||
|
# Check for dumb move: creating bad pair with negative card
|
||||||
|
partner_pos = get_column_partner_position(swap_pos)
|
||||||
|
partner = player.cards[partner_pos]
|
||||||
|
if (partner.face_up and
|
||||||
|
partner.rank == drawn.rank and
|
||||||
|
drawn_val < 0 and
|
||||||
|
not (game.options.eagle_eye and drawn.rank == Rank.JOKER)):
|
||||||
|
stats.record_dumb_move("paired_negative")
|
||||||
|
|
||||||
game.swap_card(player.id, swap_pos)
|
game.swap_card(player.id, swap_pos)
|
||||||
action = "swap"
|
action = "swap"
|
||||||
stats.record_turn(player.name, action)
|
stats.record_turn(player.name, action)
|
||||||
@ -184,6 +286,14 @@ def run_cpu_turn(
|
|||||||
decision_reason=f"swapped {drawn.rank.value} for {old_card.rank.value} at pos {swap_pos}",
|
decision_reason=f"swapped {drawn.rank.value} for {old_card.rank.value} at pos {swap_pos}",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Check for dumb moves: discarding excellent cards
|
||||||
|
if drawn.rank == Rank.JOKER:
|
||||||
|
stats.record_dumb_move("discarded_joker")
|
||||||
|
elif drawn.rank == Rank.TWO:
|
||||||
|
stats.record_dumb_move("discarded_two")
|
||||||
|
elif drawn.rank == Rank.KING:
|
||||||
|
stats.record_dumb_move("discarded_king")
|
||||||
|
|
||||||
game.discard_drawn(player.id)
|
game.discard_drawn(player.id)
|
||||||
action = "discard"
|
action = "discard"
|
||||||
stats.record_turn(player.name, action)
|
stats.record_turn(player.name, action)
|
||||||
|
|||||||
@ -40,9 +40,6 @@ class TestCardValues:
|
|||||||
opts = {'super_kings': True}
|
opts = {'super_kings': True}
|
||||||
assert get_card_value('K', opts) == -2
|
assert get_card_value('K', opts) == -2
|
||||||
|
|
||||||
opts = {'lucky_sevens': True}
|
|
||||||
assert get_card_value('7', opts) == 0
|
|
||||||
|
|
||||||
opts = {'ten_penny': True}
|
opts = {'ten_penny': True}
|
||||||
assert get_card_value('10', opts) == 1
|
assert get_card_value('10', opts) == 1
|
||||||
|
|
||||||
|
|||||||
288
server/test_auth.py
Normal file
288
server/test_auth.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
Tests for the authentication system.
|
||||||
|
|
||||||
|
Run with: pytest test_auth.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from auth import AuthManager, User, UserRole, Session, InviteCode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_manager():
|
||||||
|
"""Create a fresh auth manager with temporary database."""
|
||||||
|
# Use a temporary file for testing
|
||||||
|
fd, path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
# Create manager (this will create default admin)
|
||||||
|
manager = AuthManager(db_path=path)
|
||||||
|
|
||||||
|
yield manager
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserCreation:
|
||||||
|
"""Test user creation and retrieval."""
|
||||||
|
|
||||||
|
def test_create_user(self, auth_manager):
|
||||||
|
"""Can create a new user."""
|
||||||
|
user = auth_manager.create_user(
|
||||||
|
username="testuser",
|
||||||
|
password="password123",
|
||||||
|
email="test@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user is not None
|
||||||
|
assert user.username == "testuser"
|
||||||
|
assert user.email == "test@example.com"
|
||||||
|
assert user.role == UserRole.USER
|
||||||
|
assert user.is_active is True
|
||||||
|
|
||||||
|
def test_create_duplicate_username_fails(self, auth_manager):
|
||||||
|
"""Cannot create user with duplicate username."""
|
||||||
|
auth_manager.create_user(username="testuser", password="pass1")
|
||||||
|
user2 = auth_manager.create_user(username="testuser", password="pass2")
|
||||||
|
|
||||||
|
assert user2 is None
|
||||||
|
|
||||||
|
def test_create_duplicate_email_fails(self, auth_manager):
|
||||||
|
"""Cannot create user with duplicate email."""
|
||||||
|
auth_manager.create_user(
|
||||||
|
username="user1",
|
||||||
|
password="pass1",
|
||||||
|
email="test@example.com"
|
||||||
|
)
|
||||||
|
user2 = auth_manager.create_user(
|
||||||
|
username="user2",
|
||||||
|
password="pass2",
|
||||||
|
email="test@example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user2 is None
|
||||||
|
|
||||||
|
def test_create_admin_user(self, auth_manager):
|
||||||
|
"""Can create admin user."""
|
||||||
|
user = auth_manager.create_user(
|
||||||
|
username="newadmin",
|
||||||
|
password="adminpass",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user is not None
|
||||||
|
assert user.is_admin() is True
|
||||||
|
|
||||||
|
def test_get_user_by_id(self, auth_manager):
|
||||||
|
"""Can retrieve user by ID."""
|
||||||
|
created = auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
retrieved = auth_manager.get_user_by_id(created.id)
|
||||||
|
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.username == "testuser"
|
||||||
|
|
||||||
|
def test_get_user_by_username(self, auth_manager):
|
||||||
|
"""Can retrieve user by username."""
|
||||||
|
auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
retrieved = auth_manager.get_user_by_username("testuser")
|
||||||
|
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.username == "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Test login and session management."""
|
||||||
|
|
||||||
|
def test_authenticate_valid_credentials(self, auth_manager):
|
||||||
|
"""Can authenticate with valid credentials."""
|
||||||
|
auth_manager.create_user(username="testuser", password="correctpass")
|
||||||
|
user = auth_manager.authenticate("testuser", "correctpass")
|
||||||
|
|
||||||
|
assert user is not None
|
||||||
|
assert user.username == "testuser"
|
||||||
|
|
||||||
|
def test_authenticate_invalid_password(self, auth_manager):
|
||||||
|
"""Invalid password returns None."""
|
||||||
|
auth_manager.create_user(username="testuser", password="correctpass")
|
||||||
|
user = auth_manager.authenticate("testuser", "wrongpass")
|
||||||
|
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
def test_authenticate_nonexistent_user(self, auth_manager):
|
||||||
|
"""Nonexistent user returns None."""
|
||||||
|
user = auth_manager.authenticate("nonexistent", "anypass")
|
||||||
|
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
def test_authenticate_inactive_user(self, auth_manager):
|
||||||
|
"""Inactive user cannot authenticate."""
|
||||||
|
created = auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
auth_manager.update_user(created.id, is_active=False)
|
||||||
|
|
||||||
|
user = auth_manager.authenticate("testuser", "pass")
|
||||||
|
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
def test_create_session(self, auth_manager):
|
||||||
|
"""Can create session for authenticated user."""
|
||||||
|
user = auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
session = auth_manager.create_session(user)
|
||||||
|
|
||||||
|
assert session is not None
|
||||||
|
assert session.user_id == user.id
|
||||||
|
assert session.is_expired() is False
|
||||||
|
|
||||||
|
def test_get_user_from_session(self, auth_manager):
|
||||||
|
"""Can get user from valid session token."""
|
||||||
|
user = auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
session = auth_manager.create_session(user)
|
||||||
|
|
||||||
|
retrieved = auth_manager.get_user_from_session(session.token)
|
||||||
|
|
||||||
|
assert retrieved is not None
|
||||||
|
assert retrieved.id == user.id
|
||||||
|
|
||||||
|
def test_invalid_session_token(self, auth_manager):
|
||||||
|
"""Invalid session token returns None."""
|
||||||
|
user = auth_manager.get_user_from_session("invalid_token")
|
||||||
|
|
||||||
|
assert user is None
|
||||||
|
|
||||||
|
def test_invalidate_session(self, auth_manager):
|
||||||
|
"""Can invalidate a session."""
|
||||||
|
user = auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
session = auth_manager.create_session(user)
|
||||||
|
|
||||||
|
auth_manager.invalidate_session(session.token)
|
||||||
|
retrieved = auth_manager.get_user_from_session(session.token)
|
||||||
|
|
||||||
|
assert retrieved is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestInviteCodes:
|
||||||
|
"""Test invite code functionality."""
|
||||||
|
|
||||||
|
def test_create_invite_code(self, auth_manager):
|
||||||
|
"""Can create invite code."""
|
||||||
|
admin = auth_manager.get_user_by_username("admin")
|
||||||
|
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||||
|
|
||||||
|
assert invite is not None
|
||||||
|
assert len(invite.code) == 8
|
||||||
|
assert invite.is_valid() is True
|
||||||
|
|
||||||
|
def test_use_invite_code(self, auth_manager):
|
||||||
|
"""Can use invite code."""
|
||||||
|
admin = auth_manager.get_user_by_username("admin")
|
||||||
|
invite = auth_manager.create_invite_code(created_by=admin.id, max_uses=1)
|
||||||
|
|
||||||
|
result = auth_manager.use_invite_code(invite.code)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Check use count increased
|
||||||
|
updated = auth_manager.get_invite_code(invite.code)
|
||||||
|
assert updated.use_count == 1
|
||||||
|
|
||||||
|
def test_invite_code_max_uses(self, auth_manager):
|
||||||
|
"""Invite code respects max uses."""
|
||||||
|
admin = auth_manager.get_user_by_username("admin")
|
||||||
|
invite = auth_manager.create_invite_code(created_by=admin.id, max_uses=1)
|
||||||
|
|
||||||
|
# First use should work
|
||||||
|
auth_manager.use_invite_code(invite.code)
|
||||||
|
|
||||||
|
# Second use should fail (max_uses=1)
|
||||||
|
updated = auth_manager.get_invite_code(invite.code)
|
||||||
|
assert updated.is_valid() is False
|
||||||
|
|
||||||
|
def test_invite_code_case_insensitive(self, auth_manager):
|
||||||
|
"""Invite code lookup is case insensitive."""
|
||||||
|
admin = auth_manager.get_user_by_username("admin")
|
||||||
|
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||||
|
|
||||||
|
retrieved_lower = auth_manager.get_invite_code(invite.code.lower())
|
||||||
|
retrieved_upper = auth_manager.get_invite_code(invite.code.upper())
|
||||||
|
|
||||||
|
assert retrieved_lower is not None
|
||||||
|
assert retrieved_upper is not None
|
||||||
|
|
||||||
|
def test_deactivate_invite_code(self, auth_manager):
|
||||||
|
"""Can deactivate invite code."""
|
||||||
|
admin = auth_manager.get_user_by_username("admin")
|
||||||
|
invite = auth_manager.create_invite_code(created_by=admin.id)
|
||||||
|
|
||||||
|
auth_manager.deactivate_invite_code(invite.code)
|
||||||
|
|
||||||
|
updated = auth_manager.get_invite_code(invite.code)
|
||||||
|
assert updated.is_valid() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminFunctions:
|
||||||
|
"""Test admin-only functions."""
|
||||||
|
|
||||||
|
def test_list_users(self, auth_manager):
|
||||||
|
"""Admin can list all users."""
|
||||||
|
auth_manager.create_user(username="user1", password="pass1")
|
||||||
|
auth_manager.create_user(username="user2", password="pass2")
|
||||||
|
|
||||||
|
users = auth_manager.list_users()
|
||||||
|
|
||||||
|
# Should include admin + 2 created users
|
||||||
|
assert len(users) >= 3
|
||||||
|
|
||||||
|
def test_update_user_role(self, auth_manager):
|
||||||
|
"""Admin can change user role."""
|
||||||
|
user = auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
|
||||||
|
updated = auth_manager.update_user(user.id, role=UserRole.ADMIN)
|
||||||
|
|
||||||
|
assert updated.is_admin() is True
|
||||||
|
|
||||||
|
def test_change_password(self, auth_manager):
|
||||||
|
"""Admin can change user password."""
|
||||||
|
user = auth_manager.create_user(username="testuser", password="oldpass")
|
||||||
|
|
||||||
|
auth_manager.change_password(user.id, "newpass")
|
||||||
|
|
||||||
|
# Old password should not work
|
||||||
|
auth_fail = auth_manager.authenticate("testuser", "oldpass")
|
||||||
|
assert auth_fail is None
|
||||||
|
|
||||||
|
# New password should work
|
||||||
|
auth_ok = auth_manager.authenticate("testuser", "newpass")
|
||||||
|
assert auth_ok is not None
|
||||||
|
|
||||||
|
def test_delete_user(self, auth_manager):
|
||||||
|
"""Admin can deactivate user."""
|
||||||
|
user = auth_manager.create_user(username="testuser", password="pass")
|
||||||
|
|
||||||
|
auth_manager.delete_user(user.id)
|
||||||
|
|
||||||
|
# User should be inactive
|
||||||
|
updated = auth_manager.get_user_by_id(user.id)
|
||||||
|
assert updated.is_active is False
|
||||||
|
|
||||||
|
# User should not be able to login
|
||||||
|
auth_fail = auth_manager.authenticate("testuser", "pass")
|
||||||
|
assert auth_fail is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultAdmin:
|
||||||
|
"""Test default admin creation."""
|
||||||
|
|
||||||
|
def test_default_admin_created(self, auth_manager):
|
||||||
|
"""Default admin is created if no admins exist."""
|
||||||
|
admin = auth_manager.get_user_by_username("admin")
|
||||||
|
|
||||||
|
assert admin is not None
|
||||||
|
assert admin.is_admin() is True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
@ -148,15 +148,6 @@ class TestHouseRulesScoring:
|
|||||||
# K=-2, 3=3, columns 1&2 matched = 0
|
# K=-2, 3=3, columns 1&2 matched = 0
|
||||||
assert score == 1
|
assert score == 1
|
||||||
|
|
||||||
def test_lucky_sevens_zero(self):
|
|
||||||
"""With lucky_sevens, 7s worth 0."""
|
|
||||||
options = GameOptions(lucky_sevens=True)
|
|
||||||
self.set_hand([Rank.SEVEN, Rank.ACE, Rank.ACE,
|
|
||||||
Rank.THREE, Rank.ACE, Rank.ACE])
|
|
||||||
score = self.player.calculate_score(options)
|
|
||||||
# 7=0, 3=3, columns 1&2 matched = 0
|
|
||||||
assert score == 3
|
|
||||||
|
|
||||||
def test_ten_penny(self):
|
def test_ten_penny(self):
|
||||||
"""With ten_penny, 10s worth 1."""
|
"""With ten_penny, 10s worth 1."""
|
||||||
options = GameOptions(ten_penny=True)
|
options = GameOptions(ten_penny=True)
|
||||||
|
|||||||
@ -315,5 +315,164 @@ class TestEdgeCases:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAvoidBadPairs:
|
||||||
|
"""Test that AI avoids creating wasteful pairs with negative cards."""
|
||||||
|
|
||||||
|
def test_filter_bad_pair_positions_with_visible_two(self):
|
||||||
|
"""
|
||||||
|
When placing a 2, avoid positions where column partner is a visible 2.
|
||||||
|
|
||||||
|
Setup: Visible 2 at position 0
|
||||||
|
Placing: Another 2
|
||||||
|
Expected: Position 3 should be filtered out (would pair with position 0)
|
||||||
|
"""
|
||||||
|
from ai import filter_bad_pair_positions
|
||||||
|
|
||||||
|
game = create_test_game()
|
||||||
|
player = game.get_player("maya")
|
||||||
|
|
||||||
|
# Position 0 has a visible 2
|
||||||
|
player.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||||
|
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
|
||||||
|
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: column partner of 0
|
||||||
|
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4
|
||||||
|
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5
|
||||||
|
]
|
||||||
|
|
||||||
|
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||||
|
face_down = [3, 4, 5]
|
||||||
|
|
||||||
|
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||||
|
|
||||||
|
# Position 3 should be filtered out (would pair with visible 2 at position 0)
|
||||||
|
assert 3 not in safe_positions, (
|
||||||
|
"Position 3 should be filtered - would create wasteful 2-2 pair"
|
||||||
|
)
|
||||||
|
assert 4 in safe_positions
|
||||||
|
assert 5 in safe_positions
|
||||||
|
|
||||||
|
def test_filter_allows_positive_card_pairs(self):
|
||||||
|
"""
|
||||||
|
Positive value cards can be paired - no filtering needed.
|
||||||
|
|
||||||
|
Pairing a 5 with another 5 is GOOD (saves 10 points).
|
||||||
|
"""
|
||||||
|
from ai import filter_bad_pair_positions
|
||||||
|
|
||||||
|
game = create_test_game()
|
||||||
|
player = game.get_player("maya")
|
||||||
|
|
||||||
|
# Position 0 has a visible 5
|
||||||
|
player.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 0: visible 5
|
||||||
|
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.SEVEN, face_up=True),
|
||||||
|
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 3: column partner
|
||||||
|
Card(Suit.SPADES, Rank.NINE, face_up=False),
|
||||||
|
Card(Suit.SPADES, Rank.TEN, face_up=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
drawn_five = Card(Suit.CLUBS, Rank.FIVE)
|
||||||
|
face_down = [3, 4, 5]
|
||||||
|
|
||||||
|
safe_positions = filter_bad_pair_positions(face_down, drawn_five, player, game.options)
|
||||||
|
|
||||||
|
# All positions should be allowed - pairing 5s is good!
|
||||||
|
assert safe_positions == face_down
|
||||||
|
|
||||||
|
def test_choose_swap_avoids_pairing_twos(self):
|
||||||
|
"""
|
||||||
|
The full choose_swap_or_discard flow should avoid placing 2s
|
||||||
|
in positions that would pair them.
|
||||||
|
|
||||||
|
Run multiple times to verify randomness doesn't cause bad pairs.
|
||||||
|
"""
|
||||||
|
game = create_test_game()
|
||||||
|
maya = game.get_player("maya")
|
||||||
|
profile = get_maya_profile()
|
||||||
|
|
||||||
|
# Position 0 has a visible 2
|
||||||
|
maya.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||||
|
Card(Suit.HEARTS, Rank.FIVE, face_up=True), # Pos 1
|
||||||
|
Card(Suit.HEARTS, Rank.SIX, face_up=True), # Pos 2
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: BAD - would pair
|
||||||
|
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: OK
|
||||||
|
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
|
||||||
|
]
|
||||||
|
|
||||||
|
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||||
|
|
||||||
|
# Run 100 times - should NEVER pick position 3
|
||||||
|
bad_pair_count = 0
|
||||||
|
for _ in range(100):
|
||||||
|
swap_pos = GolfAI.choose_swap_or_discard(drawn_two, maya, profile, game)
|
||||||
|
if swap_pos == 3:
|
||||||
|
bad_pair_count += 1
|
||||||
|
|
||||||
|
assert bad_pair_count == 0, (
|
||||||
|
f"AI picked position 3 (creating 2-2 pair) {bad_pair_count}/100 times. "
|
||||||
|
"Should avoid positions that waste negative card value."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forced_swap_avoids_pairing_twos(self):
|
||||||
|
"""
|
||||||
|
Even when forced to swap from discard, AI should avoid bad pairs.
|
||||||
|
"""
|
||||||
|
from ai import filter_bad_pair_positions
|
||||||
|
|
||||||
|
game = create_test_game()
|
||||||
|
player = game.get_player("maya")
|
||||||
|
|
||||||
|
# Position 1 has a visible 2, only positions 3, 4 are face-down
|
||||||
|
player.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 1: visible 2
|
||||||
|
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=True),
|
||||||
|
Card(Suit.SPADES, Rank.EIGHT, face_up=False), # Pos 4: BAD - pairs with pos 1
|
||||||
|
Card(Suit.SPADES, Rank.NINE, face_up=False), # Pos 5: OK
|
||||||
|
]
|
||||||
|
|
||||||
|
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||||
|
face_down = [4, 5]
|
||||||
|
|
||||||
|
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||||
|
|
||||||
|
# Position 4 should be filtered out (would pair with visible 2 at position 1)
|
||||||
|
assert 4 not in safe_positions
|
||||||
|
assert 5 in safe_positions
|
||||||
|
|
||||||
|
def test_all_positions_bad_falls_back(self):
|
||||||
|
"""
|
||||||
|
If ALL positions would create bad pairs, fall back to original list.
|
||||||
|
(Must place the card somewhere)
|
||||||
|
"""
|
||||||
|
from ai import filter_bad_pair_positions
|
||||||
|
|
||||||
|
game = create_test_game()
|
||||||
|
player = game.get_player("maya")
|
||||||
|
|
||||||
|
# Only position 3 is face-down, and it would pair with visible 2 at position 0
|
||||||
|
player.cards = [
|
||||||
|
Card(Suit.HEARTS, Rank.TWO, face_up=True), # Pos 0: visible 2
|
||||||
|
Card(Suit.HEARTS, Rank.FIVE, face_up=True),
|
||||||
|
Card(Suit.HEARTS, Rank.SIX, face_up=True),
|
||||||
|
Card(Suit.SPADES, Rank.SEVEN, face_up=False), # Pos 3: only option, but bad
|
||||||
|
Card(Suit.SPADES, Rank.EIGHT, face_up=True),
|
||||||
|
Card(Suit.SPADES, Rank.NINE, face_up=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
drawn_two = Card(Suit.CLUBS, Rank.TWO)
|
||||||
|
face_down = [3] # Only option
|
||||||
|
|
||||||
|
safe_positions = filter_bad_pair_positions(face_down, drawn_two, player, game.options)
|
||||||
|
|
||||||
|
# Should return original list since there's no alternative
|
||||||
|
assert safe_positions == face_down
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user