commit a1e6661249eccd07196270f325eae00f4c0f50a0 Author: s3lph Date: Sun Oct 31 14:44:14 2021 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cf2306 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +**/__pycache__/ +*.pyc +**/*.egg-info/ +*.coverage +**/.mypy_cache/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f948e5f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +jinja3 +bottle +sudoku-manager +sudokugen +namesgenerator diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..d73f5d6 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages + +from webgames import __version__ + +setup( + name='webgames', + version=__version__, + author='s3lph', + author_email='', + description='', + license='MIT', + keywords='games,multiplayer,web', + url='', + packages=find_packages(exclude=['*.test']), + long_description='', + python_requires='>=3.6', + install_requires=[ + 'jinja2==2.11.1', + 'bottle==0.12.18', + 'sudoku-manager==1.0.6', + 'sudokugen==0.2.1', + 'namesgenerator==0.3' + ], + entry_points={ + 'console_scripts': [ + 'webgames = webgames:main' + ] + }, + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: Web Environment', + 'Framework :: Bottle', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: MIT License', + 'Topic :: Games/Entertainment :: Puzzle Games' + ], +) diff --git a/static/carcassonne-pasture.jpg b/static/carcassonne-pasture.jpg new file mode 100644 index 0000000..511d605 Binary files /dev/null and b/static/carcassonne-pasture.jpg differ diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..590af6e --- /dev/null +++ b/static/script.js @@ -0,0 +1,17 @@ + +document.getElementById('joinurl').addEventListener('click',(e) => { + var url = document.getElementById('joinurl'); + var sel = window.getSelection(); + var range = document.createRange(); + range.selectNodeContents(url); + sel.removeAllRanges(); + sel.addRange(range); + if (document.execCommand('copy') === true) { + url.classList.add('copied'); + setTimeout(() => { + url.classList.remove('copied'); + }, 0); + + } + sel.removeAllRanges(); +}); diff --git a/static/set.js b/static/set.js new file mode 100644 index 0000000..b27562a --- /dev/null +++ b/static/set.js @@ -0,0 +1,60 @@ + +function cardsEqual(a, b) { + return a.getAttribute('data-color') === b.getAttribute('data-color') + && a.getAttribute('data-shape') === b.getAttribute('data-shape') + && a.getAttribute('data-count') === b.getAttribute('data-count') + && a.getAttribute('data-infill') === b.getAttribute('data-infill'); +} + +function replaceCard(a, b) { + a.innerHTML = b.innerHTML; + a.setAttribute('data-color', b.getAttribute('data-color')); + a.setAttribute('data-shape', b.getAttribute('data-shape')); + a.setAttribute('data-count', b.getAttribute('data-count')); + a.setAttribute('data-infill', b.getAttribute('data-infill')); +} + +function markNewCard(card) { + card.classList.add('new'); + setTimeout(() => { + card.classList.remove('new'); + }, 3000); +} + +function update() { + var req = new XMLHttpRequest(); + req.addEventListener('load', (ev) => { + var parser = new DOMParser(); + var doc = parser.parseFromString(req.responseText, 'text/html'); + // Replace playing field cards + var field = document.getElementById('playingfield'); + var aNodes = Array.from(field.children); + var bNodes = Array.from(doc.getElementById('playingfield').children); + for (var i = 0; i < Math.min(aNodes.length, bNodes.length); ++i) { + if (!cardsEqual(aNodes[i], bNodes[i])) { + replaceCard(aNodes[i], bNodes[i]); + markNewCard(aNodes[i]); + } + } + if (bNodes.length < aNodes.length) { + for (var i = bNodes.length; i < aNodes.length; ++i) { + aNodes[i].remove(); + } + } + if (bNodes.length > aNodes.length) { + for (var i = aNodes.length; i < bNodes.length; ++i) { + field.appendChild(bNodes[i]); + markNewCard(bNodes[i]); + } + } + // Replace gamestats and scores + document.getElementById('gamestats').innerHTML = + doc.getElementById('gamestats').innerHTML; + document.getElementById('scorelist').innerHTML = + doc.getElementById('scorelist').innerHTML; + }); + req.open('GET', window.location.href); + req.send(); +} + +setInterval(update, 500); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6710686 --- /dev/null +++ b/static/style.css @@ -0,0 +1,144 @@ + +html, body { + padding: 0; + margin: 0; + background: #999999; + font-family: sans-serif; +} + +main { + width: 1200px; + left: 0; + right: 0; + margin: auto; +} + +code#joinurl { + cursor: pointer; + position: relative; +} + +code#joinurl::after { + content: "Copied!"; + text-align: center; + font-family: sans-serif; + font-weight: bold; + pointer-events: none; + position: absolute; + top: -1px; + right: -1px; + bottom: -1px; + left: -1px; + background: #ffffff; + opacity: 0; + transition: opacity 1s; +} + +code#joinurl.copied::after { + opacity: 1; + transition: opacity 0s; +} + +/* + * SUDOKU + */ + +table, td { + border: 1px solid black; + border-collapse: collapse; + padding: 0; + background: white; + text-align: center; +} + +tr.row-1 > td { + border-top: 3px solid black; +} + +tr.row-3 > td, tr.row-6 > td, tr.row-9 > td { + border-bottom: 3px solid black; +} + +td.col-1 { + border-left: 3px solid black; +} + +td.col-3, td.col-6, td.col-9 { + border-right: 3px solid black; +} + +input.sudoku-field:checked + label.sudoku-field { + background: #aaaaff; +} + +input.sudoku-field { + display: none; +} + +label.sudoku-field { + display: inline-block; + width: 45px; + height: 45px; + font-size: 2em; + margin: 0; + line-height: 45px; +} + +label.sudoku-field-original { + background: #aaffaa; +} + +input.fill { + width: 100% !important; +} + +#sudoku-input input { + width: 45px; + height: 45px; + font-size: 2em; +} + + + +/* + * SET + */ + +#playingfield { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: flex-start; + max-height: 700px; +} + +#playingfield .card > input { + display: none; +} + +#playingfield .card.new > label #card { + filter: url(#new); +} + +#playingfield .card > input:checked ~ label #card { + filter: url(#drop); +} +#playingfield .card > input:checked ~ label .svgcard { + transform: rotate(5deg); + transform-origin: 70px 100px; +} + +.playercardlist .card { + display: inline-block; + margin-right: -120px; +} + +.playercardlist .playercard-0 { + transform: rotate(-10deg) scale(0.5); +} +.playercardlist .playercard-1 { + transform: scale(0.5); +} +.playercardlist .playercard-2 { + transform: rotate(10deg) scale(0.5) translate(0px, 10px); +} diff --git a/static/sudoku.js b/static/sudoku.js new file mode 100644 index 0000000..e69de29 diff --git a/templates/carcassonne/001.svg b/templates/carcassonne/001.svg new file mode 100644 index 0000000..64791ed --- /dev/null +++ b/templates/carcassonne/001.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/011.svg b/templates/carcassonne/011.svg new file mode 100644 index 0000000..f008d11 --- /dev/null +++ b/templates/carcassonne/011.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/021.svg b/templates/carcassonne/021.svg new file mode 100644 index 0000000..7a31d4a --- /dev/null +++ b/templates/carcassonne/021.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/carcassonne/022.svg b/templates/carcassonne/022.svg new file mode 100644 index 0000000..0ff5d75 --- /dev/null +++ b/templates/carcassonne/022.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/carcassonne/031.svg b/templates/carcassonne/031.svg new file mode 100644 index 0000000..b6efac0 --- /dev/null +++ b/templates/carcassonne/031.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/templates/carcassonne/041.svg b/templates/carcassonne/041.svg new file mode 100644 index 0000000..c34fa29 --- /dev/null +++ b/templates/carcassonne/041.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/templates/carcassonne/101.svg b/templates/carcassonne/101.svg new file mode 100644 index 0000000..bb18838 --- /dev/null +++ b/templates/carcassonne/101.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/carcassonne/121.svg b/templates/carcassonne/121.svg new file mode 100644 index 0000000..cd96855 --- /dev/null +++ b/templates/carcassonne/121.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/122.svg b/templates/carcassonne/122.svg new file mode 100644 index 0000000..a00df82 --- /dev/null +++ b/templates/carcassonne/122.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/123.svg b/templates/carcassonne/123.svg new file mode 100644 index 0000000..9e648d0 --- /dev/null +++ b/templates/carcassonne/123.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/131.svg b/templates/carcassonne/131.svg new file mode 100644 index 0000000..26a9ee1 --- /dev/null +++ b/templates/carcassonne/131.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/templates/carcassonne/201.svg b/templates/carcassonne/201.svg new file mode 100644 index 0000000..8df877a --- /dev/null +++ b/templates/carcassonne/201.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/carcassonne/202.svg b/templates/carcassonne/202.svg new file mode 100644 index 0000000..a51e569 --- /dev/null +++ b/templates/carcassonne/202.svg @@ -0,0 +1,8 @@ + + + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/203.svg b/templates/carcassonne/203.svg new file mode 100644 index 0000000..c69fece --- /dev/null +++ b/templates/carcassonne/203.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/204.svg b/templates/carcassonne/204.svg new file mode 100644 index 0000000..8e296ac --- /dev/null +++ b/templates/carcassonne/204.svg @@ -0,0 +1,7 @@ + + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/205.svg b/templates/carcassonne/205.svg new file mode 100644 index 0000000..92c0b45 --- /dev/null +++ b/templates/carcassonne/205.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/206.svg b/templates/carcassonne/206.svg new file mode 100644 index 0000000..3ae83dc --- /dev/null +++ b/templates/carcassonne/206.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/221.svg b/templates/carcassonne/221.svg new file mode 100644 index 0000000..69dd8e3 --- /dev/null +++ b/templates/carcassonne/221.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/222.svg b/templates/carcassonne/222.svg new file mode 100644 index 0000000..dbb599e --- /dev/null +++ b/templates/carcassonne/222.svg @@ -0,0 +1,9 @@ + + + + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/301.svg b/templates/carcassonne/301.svg new file mode 100644 index 0000000..b624c34 --- /dev/null +++ b/templates/carcassonne/301.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/302.svg b/templates/carcassonne/302.svg new file mode 100644 index 0000000..1c41852 --- /dev/null +++ b/templates/carcassonne/302.svg @@ -0,0 +1,7 @@ + + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/311.svg b/templates/carcassonne/311.svg new file mode 100644 index 0000000..a779a2e --- /dev/null +++ b/templates/carcassonne/311.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/312.svg b/templates/carcassonne/312.svg new file mode 100644 index 0000000..a4f9fe6 --- /dev/null +++ b/templates/carcassonne/312.svg @@ -0,0 +1,9 @@ + + + + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/401.svg b/templates/carcassonne/401.svg new file mode 100644 index 0000000..093a893 --- /dev/null +++ b/templates/carcassonne/401.svg @@ -0,0 +1,6 @@ + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/carcassonne.html.j2 b/templates/carcassonne/carcassonne.html.j2 new file mode 100644 index 0000000..6fe57eb --- /dev/null +++ b/templates/carcassonne/carcassonne.html.j2 @@ -0,0 +1,161 @@ + + + +{% if not done %} + +{% endif %} + + + + {{ field }} + {% if done %} + Game Over! Return to lobby + {% else %} + {% if current_player.uuid == me.uuid %} + It is your turn! + {% if phase == 'place' %}Place your tile{% else %}Claim your resource{% endif %} +
+ {% if phase == 'place' %} + + {{ card }} + + + + + {% endif %} + {% if phase == 'claim' %} + + + {% endif %} +
+ + {% else %} + It is {{ game._players[current_player.uuid].name }}'s turn. + {% endif %} + {% endif %} + + + {% for player in players | sort(attribute='score', reverse=true) %} + + + + + + {% endfor %} +
NameFollowersScore
{{ game._players[player.uuid].name }}{{ player.followers | selectattr('resource', 'none') | list | length }}{{ player.score }}
+ Deck: {{ deck | length }} + + diff --git a/templates/carcassonne/defense.svg b/templates/carcassonne/defense.svg new file mode 100644 index 0000000..8c02d4f --- /dev/null +++ b/templates/carcassonne/defense.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/templates/carcassonne/empty.svg b/templates/carcassonne/empty.svg new file mode 100644 index 0000000..f5cf876 --- /dev/null +++ b/templates/carcassonne/empty.svg @@ -0,0 +1,4 @@ + + + + diff --git a/templates/carcassonne/field.svg b/templates/carcassonne/field.svg new file mode 100644 index 0000000..5469f0c --- /dev/null +++ b/templates/carcassonne/field.svg @@ -0,0 +1,14 @@ + + {% for card in cards %} + {{ card }} + {% endfor %} + {% for empty in empties %} + + + + + {% endfor %} + {% for follower in follows %} + {{ follower }} + {% endfor %} + \ No newline at end of file diff --git a/templates/carcassonne/follower.svg b/templates/carcassonne/follower.svg new file mode 100644 index 0000000..3859555 --- /dev/null +++ b/templates/carcassonne/follower.svg @@ -0,0 +1,3 @@ + + + diff --git a/templates/carcassonne/follower_pasture.svg b/templates/carcassonne/follower_pasture.svg new file mode 100644 index 0000000..3bae738 --- /dev/null +++ b/templates/carcassonne/follower_pasture.svg @@ -0,0 +1,3 @@ + + + diff --git a/templates/carcassonne/he-001.svg b/templates/carcassonne/he-001.svg new file mode 100644 index 0000000..391274a --- /dev/null +++ b/templates/carcassonne/he-001.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/he-011.svg b/templates/carcassonne/he-011.svg new file mode 100644 index 0000000..b430f65 --- /dev/null +++ b/templates/carcassonne/he-011.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/he-021.svg b/templates/carcassonne/he-021.svg new file mode 100644 index 0000000..82f8023 --- /dev/null +++ b/templates/carcassonne/he-021.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/templates/carcassonne/he-101.svg b/templates/carcassonne/he-101.svg new file mode 100644 index 0000000..3555a15 --- /dev/null +++ b/templates/carcassonne/he-101.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/he-111.svg b/templates/carcassonne/he-111.svg new file mode 100644 index 0000000..32e2eef --- /dev/null +++ b/templates/carcassonne/he-111.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/templates/carcassonne/ic-021-inn.svg b/templates/carcassonne/ic-021-inn.svg new file mode 100644 index 0000000..d2aa83b --- /dev/null +++ b/templates/carcassonne/ic-021-inn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/ic-021-monastery.svg b/templates/carcassonne/ic-021-monastery.svg new file mode 100644 index 0000000..fbe5fd3 --- /dev/null +++ b/templates/carcassonne/ic-021-monastery.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/templates/carcassonne/ic-022-inn.svg b/templates/carcassonne/ic-022-inn.svg new file mode 100644 index 0000000..2554bae --- /dev/null +++ b/templates/carcassonne/ic-022-inn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/ic-031-inn.svg b/templates/carcassonne/ic-031-inn.svg new file mode 100644 index 0000000..e3a3849 --- /dev/null +++ b/templates/carcassonne/ic-031-inn.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/templates/carcassonne/ic-041.svg b/templates/carcassonne/ic-041.svg new file mode 100644 index 0000000..ee567a4 --- /dev/null +++ b/templates/carcassonne/ic-041.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/ic-101-branch.svg b/templates/carcassonne/ic-101-branch.svg new file mode 100644 index 0000000..a19a723 --- /dev/null +++ b/templates/carcassonne/ic-101-branch.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/carcassonne/ic-101-road.svg b/templates/carcassonne/ic-101-road.svg new file mode 100644 index 0000000..5409343 --- /dev/null +++ b/templates/carcassonne/ic-101-road.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/ic-122-inn.svg b/templates/carcassonne/ic-122-inn.svg new file mode 100644 index 0000000..f43a6b4 --- /dev/null +++ b/templates/carcassonne/ic-122-inn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/templates/carcassonne/ic-202-roads.svg b/templates/carcassonne/ic-202-roads.svg new file mode 100644 index 0000000..1c4034d --- /dev/null +++ b/templates/carcassonne/ic-202-roads.svg @@ -0,0 +1,12 @@ + + + + + + + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/ic-203-road-inn.svg b/templates/carcassonne/ic-203-road-inn.svg new file mode 100644 index 0000000..89727b1 --- /dev/null +++ b/templates/carcassonne/ic-203-road-inn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/templates/carcassonne/ic-203-road.svg b/templates/carcassonne/ic-203-road.svg new file mode 100644 index 0000000..b1d522f --- /dev/null +++ b/templates/carcassonne/ic-203-road.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/ic-221-crossing.svg b/templates/carcassonne/ic-221-crossing.svg new file mode 100644 index 0000000..4c78a7d --- /dev/null +++ b/templates/carcassonne/ic-221-crossing.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/templates/carcassonne/ic-222-inn.svg b/templates/carcassonne/ic-222-inn.svg new file mode 100644 index 0000000..0b1815e --- /dev/null +++ b/templates/carcassonne/ic-222-inn.svg @@ -0,0 +1,12 @@ + + + + + + + + + + {% include "carcassonne/defense.svg" %} + + diff --git a/templates/carcassonne/ic-301-split.svg b/templates/carcassonne/ic-301-split.svg new file mode 100644 index 0000000..d595455 --- /dev/null +++ b/templates/carcassonne/ic-301-split.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/ic-302.svg b/templates/carcassonne/ic-302.svg new file mode 100644 index 0000000..c37b45c --- /dev/null +++ b/templates/carcassonne/ic-302.svg @@ -0,0 +1,6 @@ + + + + + {% include "carcassonne/defense.svg" %} + diff --git a/templates/carcassonne/ic-401-cathedral.svg b/templates/carcassonne/ic-401-cathedral.svg new file mode 100644 index 0000000..b41330a --- /dev/null +++ b/templates/carcassonne/ic-401-cathedral.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/carcassonne/ic-401-pasture.svg b/templates/carcassonne/ic-401-pasture.svg new file mode 100644 index 0000000..963667c --- /dev/null +++ b/templates/carcassonne/ic-401-pasture.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/r2-city.svg b/templates/carcassonne/r2-city.svg new file mode 100644 index 0000000..977781b --- /dev/null +++ b/templates/carcassonne/r2-city.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/r2-citybridge.svg b/templates/carcassonne/r2-citybridge.svg new file mode 100644 index 0000000..38f7de2 --- /dev/null +++ b/templates/carcassonne/r2-citybridge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/templates/carcassonne/r2-curve.svg b/templates/carcassonne/r2-curve.svg new file mode 100644 index 0000000..fdd1299 --- /dev/null +++ b/templates/carcassonne/r2-curve.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/r2-fork.svg b/templates/carcassonne/r2-fork.svg new file mode 100644 index 0000000..1acbd89 --- /dev/null +++ b/templates/carcassonne/r2-fork.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/r2-lake.svg b/templates/carcassonne/r2-lake.svg new file mode 100644 index 0000000..29ed058 --- /dev/null +++ b/templates/carcassonne/r2-lake.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/templates/carcassonne/r2-lake2.svg b/templates/carcassonne/r2-lake2.svg new file mode 100644 index 0000000..ef67060 --- /dev/null +++ b/templates/carcassonne/r2-lake2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/templates/carcassonne/r2-monastery.svg b/templates/carcassonne/r2-monastery.svg new file mode 100644 index 0000000..4d5419e --- /dev/null +++ b/templates/carcassonne/r2-monastery.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/carcassonne/r2-road.svg b/templates/carcassonne/r2-road.svg new file mode 100644 index 0000000..c99a1ec --- /dev/null +++ b/templates/carcassonne/r2-road.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/templates/carcassonne/r2-road2.svg b/templates/carcassonne/r2-road2.svg new file mode 100644 index 0000000..5b16c0b --- /dev/null +++ b/templates/carcassonne/r2-road2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/templates/carcassonne/r2-roadcity.svg b/templates/carcassonne/r2-roadcity.svg new file mode 100644 index 0000000..3d1fca0 --- /dev/null +++ b/templates/carcassonne/r2-roadcity.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/templates/carcassonne/r2-source.svg b/templates/carcassonne/r2-source.svg new file mode 100644 index 0000000..82d1d94 --- /dev/null +++ b/templates/carcassonne/r2-source.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/cwa/base.html.j2 b/templates/cwa/base.html.j2 new file mode 100644 index 0000000..2530a88 --- /dev/null +++ b/templates/cwa/base.html.j2 @@ -0,0 +1,13 @@ + + + + + + Game + + + + {%- block body %} + {%- endblock %} + + diff --git a/templates/cwa/set.html.j2 b/templates/cwa/set.html.j2 new file mode 100644 index 0000000..0a0a331 --- /dev/null +++ b/templates/cwa/set.html.j2 @@ -0,0 +1,58 @@ +{% extends 'cwa/base.html.j2' %} + +{%- block body %} +

Set: {{ game.human_id }}

+ +
+ +
+ {%- for card in cards %} + +
+ + +
+ {%- endfor %} + +
+ + + +

+ {%- if game.puzzle.is_completed %} + Game Over Return to lobby + {%- else %} + Draw votes: {{ draw_votes }} / {{ draw_majority }} + Deck: {{ deck | length }} + {% endif %} +

+ +
+ +

Scores

+
    + {%- for score in game.scoreboard %} +
  1. + {{ game.players[score['player']].name }} + {{ score['score'] }} +
    + {%- for symbol in lastset[score['player']] %} +
    + {%- include 'cwa/setcard.svg.j2' %} +
    + {%- endfor %} +
    +
  2. + {%- endfor %} +
+ Refresh + + +{%- endblock %} diff --git a/templates/cwa/setcard.svg.j2 b/templates/cwa/setcard.svg.j2 new file mode 100644 index 0000000..662a64f --- /dev/null +++ b/templates/cwa/setcard.svg.j2 @@ -0,0 +1,50 @@ + +{%- macro fill(symbol) -%} + {%- if symbol.infill == Infill.NONE -%} + none + {%- elif symbol.infill == Infill.SEMI -%} + url(#semifill-{{ symbol.color.value.name }}) + {%- elif symbol.infill == Infill.FULL -%} + {{- symbol.color.value.html -}} + {%- endif -%} +{%- endmacro -%} + + + + {{ symbol.count }} {{ symbol.shape }} {{ symbol.color }} {{ symbol.infill }} + + {%- for color in Color %} + + + + {%- endfor %} + + + + + + + + + + + + + {%- for i in range(symbol.count.value) %} + {%- set ybase = 80 - 25*(symbol.count.value - 1) + 50*i %} + {%- if symbol.shape == Shape.RECTANGLE %} + {#- rect #} + + {%- elif symbol.shape == Shape.TILDE %} + {#- tilde #} + + {%- elif symbol.shape == Shape.ELLIPSE %} + {#- ellipse #} + + {%- endif %} + {%- endfor %} + + + + diff --git a/templates/cwa/sudoku.html.j2 b/templates/cwa/sudoku.html.j2 new file mode 100644 index 0000000..7dc8764 --- /dev/null +++ b/templates/cwa/sudoku.html.j2 @@ -0,0 +1,68 @@ +{% extends 'cwa/base.html.j2' %} + +{%- block body %} +

Sudoku: {{ game.human_id }} ({{ duration }})

+
+
+
+ + {%- for row in range(1, 10) %} + + {%- for col in range(1, 10) %} + + {%- endfor %} + + {%- endfor %} +
+ {%- if puzzle.original[row-1][col-1] %} + + {%- else %} + + + {%- endif %} +
+
+
+ + {%- for row in range(0, 3) %} + + {%- for col in range(1, 4) %} + + {%- endfor %} + + {%- endfor %} + + + +
+ +
+ +
+
+
+
+

Players in Game

+ +

Scores

+
    + {%- for score in game.scoreboard %} +
  1. + {{ game.players[score['player']].name }} + {{ score['duration'] }} +
  2. + {%- endfor %} +
+ Refresh +{%- endblock %} diff --git a/templates/cwa/welcome.html.j2 b/templates/cwa/welcome.html.j2 new file mode 100644 index 0000000..bc9a76a --- /dev/null +++ b/templates/cwa/welcome.html.j2 @@ -0,0 +1,15 @@ +{% extends 'cwa/base.html.j2' %} + +{% block body %} +

New Game

+ +
+
    +
  1. Choose a name:
  2. +
  3. Join or start a game
  4. +
  5. Invite other players
  6. +
  7. Play!
  8. +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/cwa/welcome1.html.j2 b/templates/cwa/welcome1.html.j2 new file mode 100644 index 0000000..f8f9908 --- /dev/null +++ b/templates/cwa/welcome1.html.j2 @@ -0,0 +1,20 @@ +{% extends 'cwa/base.html.j2' %} + +{% block body %} +

New Game

+ +
+
    +
  1. Choose a name: {{ player_name }}
  2. +
  3. Join or start a game: + +
  4. +
  5. Invite other players
  6. +
  7. Play!
  8. +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/cwa/welcome2.html.j2 b/templates/cwa/welcome2.html.j2 new file mode 100644 index 0000000..2f3bd28 --- /dev/null +++ b/templates/cwa/welcome2.html.j2 @@ -0,0 +1,29 @@ +{% extends 'cwa/base.html.j2' %} + +{% block body %} +

{{ game.puzzle_name }}: {{ game.human_id }}

+ +
+
    +
  1. Choose a name: {{ player.name }}
  2. +
  3. Join or start a game of {{ game.puzzle_name }}: {{ game.human_id }}
  4. +
  5. Invite other players: {{ host }}{{ baseurl }}{{ game.human_id }}
  6. +
  7. +
+ +

Game Options

+ +{{ game.get_extra_options_html() }} + +

Players in Game

+ +Refresh + +
+ + +{% endblock %} diff --git a/templates/cwa/welcome3.html.j2 b/templates/cwa/welcome3.html.j2 new file mode 100644 index 0000000..395d171 --- /dev/null +++ b/templates/cwa/welcome3.html.j2 @@ -0,0 +1,15 @@ +{% extends 'cwa/base.html.j2' %} + +{% block body %} +

{{ game.puzzle_name }}: {{ game.human_id }}

+ +
+
    +
  1. Choose a name:
  2. +
  3. Join or start a game of {{ game.puzzle_name }}: {{ game.human_id }}
  4. +
  5. Invite other players
  6. +
  7. Play!
  8. +
+ +
+{% endblock %} \ No newline at end of file diff --git a/templates/sudoku.example.html b/templates/sudoku.example.html new file mode 100644 index 0000000..2ea31de --- /dev/null +++ b/templates/sudoku.example.html @@ -0,0 +1,658 @@ + + + + + + Sudoku + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + diff --git a/templates/sudoku.html b/templates/sudoku.html new file mode 100644 index 0000000..72d6856 --- /dev/null +++ b/templates/sudoku.html @@ -0,0 +1,55 @@ + + + + + + Sudoku + + + +
+
+
+ + {% for row in range(1, 10) %} + + {% for col in range(1, 10) %} + + {% endfor %} + + {% endfor %} +
+ {% if sudoku_original[row-1][col-1] %} + + {% else %} + + + {% endif %} +
+
+
+ + {% for row in range(0, 3) %} + + {% for col in range(1, 4) %} + + {% endfor %} + + {% endfor %} + + + +
+ +
+ +
+
+
+
+ + diff --git a/webgames/__init__.py b/webgames/__init__.py new file mode 100644 index 0000000..373d726 --- /dev/null +++ b/webgames/__init__.py @@ -0,0 +1,2 @@ + +__version__ = '0.1' diff --git a/webgames/__main__.py b/webgames/__main__.py new file mode 100644 index 0000000..3ba2cf6 --- /dev/null +++ b/webgames/__main__.py @@ -0,0 +1,165 @@ +import sys +import json +from uuid import UUID +from datetime import datetime + +import jinja2 +from bottle import Bottle, abort, request, redirect, static_file +from namesgenerator import get_random_name + +from webgames import api +from webgames.api import apiv1, GAMES, PLAYERS +from webgames.human import HumanID +from webgames.game import GameState + + +app = Bottle() +app.jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader('templates')) + + +@app.get('/') +def get__root(): + scheme, host, *_ = request.urlparts + tmpl = app.jinja_env.get_template('cwa/welcome.html.j2') + example_name = get_random_name(' ').title() + return tmpl.render(placeholder=example_name, host=f'{scheme}://{host}', baseurl=f'{urlbase}/') + + +@app.get('/favicon.ico') +def get_favicon(): + redirect('/static/favicon.ico') + + +@app.post('/') +def post__root(): + scheme, host, *_ = request.urlparts + name = request.forms.get('name') + placeholder = request.forms.get('placeholder') + if not name: + name = placeholder + uuid = api.create_player(name) + redirect(f'{urlbase}/{str(uuid)}') + + +@app.get('/') +@app.get('//') +def get_player(player: str): + scheme, host, *_ = request.urlparts + if len(player) == 0: + return get__root() + elif len(player) == 4: + return get__blank_game(player) + player_id = UUID(player) + player = api.get_player(player_id) + tmpl = app.jinja_env.get_template('cwa/welcome1.html.j2') + return tmpl.render(player_name=player.name, + player_uuid=str(player.uuid), host=f'{scheme}://{host}', baseurl=f'{urlbase}/') + + +@app.post('/') +@app.post('//') +def post_player(player_id: str): + scheme, host, *_ = request.urlparts + if len(player_id) == 4: + return post__blank_game(player_id) + playerid = UUID(player_id) + puzzle = request.forms.get('puzzle') + humanid = request.forms.get('game') + try: + game_id = HumanID.resolve(humanid) + except: + game_id = api.create_game(puzzle) + game = api.get_game(game_id) + player = api.get_player(playerid) + game.join(player) + redirect(f'{urlbase}/{player_id}/{str(game.uuid)}/lobby') + + +def get__blank_game(game_id: str): + scheme, host, *_ = request.urlparts + try: + gameid = HumanID.resolve(game_id) + except: + redirect(f'{urlbase}') + game = api.get_game(gameid) + tmpl = app.jinja_env.get_template('cwa/welcome3.html.j2') + example_name = get_random_name(' ').title() + return tmpl.render(game=game,placeholder=example_name, host=f'{scheme}://{host}', baseurl=f'{urlbase}/') + + +def post__blank_game(game_id: str): + scheme, host, *_ = request.urlparts + try: + gameid = HumanID.resolve(game_id) + except: + redirect(f'{urlbase}/') + game = api.get_game(gameid) + name = request.forms.get('name') + placeholder = request.forms.get('placeholder') + if not name: + name = placeholder + uuid = api.create_player(name) + player = api.get_player(uuid) + game.join(player) + redirect(f'{urlbase}/{uuid}/{game.uuid}/lobby') + + +@app.get('///lobby') +@app.get('///lobby/') +def get_player_game_lobby(player_id: str, game_id: str): + scheme, host, *_ = request.urlparts + playerid = UUID(player_id) + gameid = UUID(game_id) + player = api.get_player(playerid) + game = api.get_game(gameid) + if game.state == GameState.RUNNING: + redirect(f'{urlbase}/{player_id}/{game_id}/play') + elif game.state == GameState.FINISHED: + redirect(f'{urlbase}/{player_id}/{game_id}/done') + tmpl = app.jinja_env.get_template('cwa/welcome2.html.j2') + return tmpl.render(player=player, game=game, host=f'{scheme}://{host}', baseurl=f'{urlbase}/') + + +@app.get('///play') +@app.get('///play/') +def get_player_game_play(player_id: str, game_id: str): + playerid = UUID(player_id) + gameid = UUID(game_id) + player = api.get_player(playerid) + game = api.get_game(gameid) + if game.state == GameState.LOBBY: + game.begin({}, baseurl=f'{urlbase}/') + duration = str(datetime.utcnow() - game.start) + return game.render(app.jinja_env, player, duration) + + +@app.post('///play') +@app.post('///play/') +def post_player_game_play(player_id: str, game_id: str): + playerid = UUID(player_id) + gameid = UUID(game_id) + player = api.get_player(playerid) + game = api.get_game(gameid) + if game.state == GameState.LOBBY: + game.begin(options=request.forms, urlbase=f'{urlbase}/') + else: + game.process_action(playerid, request.forms) + duration = str(datetime.utcnow() - game.start) + return game.render(app.jinja_env, player, duration) + +#@app.error(404) +#def error_404(error): +# redirect('/', 303) + + +@app.get('/static/') +def static(filename): + return static_file(filename, root='static') + + +host = sys.argv[1] if len(sys.argv) >= 3 else '127.0.0.1' +port = sys.argv[-1] if len(sys.argv) > 1 else 8080 +urlbase = sys.argv[2] if len(sys.argv) >= 4 else '' + +app.mount('/api/v1', apiv1) +app.run(host=host, port=port) diff --git a/webgames/api.py b/webgames/api.py new file mode 100644 index 0000000..3af9bf8 --- /dev/null +++ b/webgames/api.py @@ -0,0 +1,184 @@ +from typing import Optional + +import json +from uuid import UUID + +from bottle import Bottle, abort, request + +from webgames.game import Game, GameState +from webgames.player import Player +from webgames.human import HumanID + + +GAMES = {} + + +PLAYERS = {} + + +apiv1 = Bottle() + + +@apiv1.get('/humanid/') +def get_humanid_humanid(humanid: str) -> UUID: + try: + longid: Optional[UUID] = HumanID.resolve(humanid) + except KeyError: + longid = None + return json.dumps({ + 'uuid': str(longid), + 'human_id': str(humanid) + }) + + +def create_player(name: str) -> UUID: + player = Player(name) + PLAYERS[player.uuid] = player + return player.uuid + +@apiv1.post('/player') +def post_player(): + name = request.json.get('name', None) + create_player(name) + return get_player_uuid(str(player.uuid)) + + +def get_player(uuid: UUID): + try: + return PLAYERS[uuid] + except KeyError: + abort(404, 'Player not found') + +@apiv1.get('/player/') +def get_player_uuid(uuid: str): + longid = UUID(uuid) + try: + player = PLAYERS[longid] + except KeyError: + abort(404, 'Player not found') + return json.dumps({ + 'uuid': str(player.uuid), + 'name': str(player.name) + }) + + +def create_game(puzzle: str, puzzle_args = None) -> UUID: + game = Game(puzzle, puzzle_args) + GAMES[game.uuid] = game + return game.uuid + +@apiv1.post('/game') +def post_game(): + puzzle = request.json.get('difficulty', 'sudoku') + puzzle_args = request.json.get('puzzle_args', None) + game = Game(puzzle, puzzle_args) + GAMES[game.uuid] = game + return get_game_uuid(str(game.uuid)) + + +def get_game(uuid: UUID): + try: + return GAMES[uuid] + except KeyError: + abort(404, 'Game not found') + + +@apiv1.get('/game/') +def get_game_uuid(uuid: str): + longid = UUID(uuid) + try: + game = GAMES[longid] + except KeyError: + abort(404, 'Game not found') + players = [ + { + 'uuid': str(pid), + 'name': player.name + } + for pid, player in game.players.items() + ] + puzzles = [ + { + 'uuid': str(pid), + 'progress': puzzle.progress + } + for pid, puzzle in game.puzzles.items() + ] + scores = [ + { + 'player': str(item['player']), + 'duration': str(item['duration']) + } + for item in game.scoreboard + ] + return json.dumps({ + 'uuid': str(game.uuid), + 'human_id': str(game.human_id), + 'state': str(game.state), + 'players': players, + 'puzzles': puzzles, + 'scores': scores, + 'start': game.start.timestamp() if game.start else None, + 'end': game.end.timestamp() if game.end else None + }) + + +@apiv1.post('/game//join') +def post_game_uuid_join(uuid: str): + pid: str = request.json.get('uuid') + game_id = UUID(uuid) + player_id = UUID(pid) + try: + game = GAMES[game_id] + except KeyError: + abort(404, 'Game not found') + try: + player = PLAYERS[player_id] + except KeyError: + abort(404, 'Player not found') + game.join(player) + + +@apiv1.post('/game//start') +def post_game_uuid_start(uuid: str): + game_id = UUID(uuid) + try: + game = GAMES[game_id] + except KeyError: + abort(404, 'Game not found') + game.begin(options=request.json) + + +@apiv1.post('/game//conclude') +def post_game_uuid_conclude(uuid: str): + game_id = UUID(uuid) + try: + game = GAMES[game_id] + except KeyError: + abort(404, 'Game not found') + game.conclude() + + +@apiv1.get('/game//puzzle/') +def post_game_uuid_action(gid: str, pid: str): + game_id = UUID(gid) + try: + game = GAMES[game_id] + except KeyError: + abort(404, 'Game not found') + player_id = UUID(pid) + return json.dumps(game.serialize_puzzle(player_id)) + + +@apiv1.post('/game//puzzle//action') +def post_game_gid_puzzle_pid_action(gid: str, pid: str): + game_id = UUID(gid) + try: + game = GAMES[game_id] + except KeyError: + abort(404, 'Game not found') + player_id = UUID(pid) + action = request.json.get('action') + game.process_action(player_id, action) + + diff --git a/webgames/carcassonne.py b/webgames/carcassonne.py new file mode 100644 index 0000000..ee8a9ce --- /dev/null +++ b/webgames/carcassonne.py @@ -0,0 +1,910 @@ +import random +import enum +from uuid import uuid4, UUID + +from webgames.puzzle import Puzzle + + +class _Color: + + def __init__(self, name, html, ansi): + self.name = name + self.html = html + self.ansi = ansi + + +class Color(enum.Enum): + RED = _Color('red', '#ff0000', '\033[31m') + GREEN = _Color('green', '#00aa00', '\033[32m') + BLUE = _Color('blue', '#0000ff', '\033[34m') + YELLOW = _Color('yellow', '#ff00ff', '\093[33m') + BLACK = _Color('black', '#000000', '\033[30m') + GREY = _Color('grey', '#888888', '\033[90m') + + +class Phase(enum.Enum): + PLACE = 1 + CLAIM = 2 + + +class ResourceBoundary(enum.Enum): + LEFT = 1 + TOP = 2 + RIGHT = 3 + BOTTOM = 4 + LEFT_TOP = 5 + TOP_LEFT = 6 + TOP_RIGHT = 7 + RIGHT_TOP = 8 + RIGHT_BOTTOM = 9 + BOTTOM_RIGHT = 10 + BOTTOM_LEFT = 11 + LEFT_BOTTOM = 12 + + +class Card: + + def __init__(self, monastery=None, shrine=None, city_defense=False, template='', + road_top=None, road_bottom=None, road_left=None, road_right=None, + city_top=None, city_bottom=None, city_left=None, city_right=None, + pasture_lt=None, pasture_rt=None, pasture_rb=None, pasture_lb=None, + pasture_tl=None, pasture_tr=None, pasture_br=None, pasture_bl=None, + inn=None, cathedral=None): + self.resources = {} + self.template = template + self.rotation = 0 + self.city_defense = city_defense + if monastery: + self.resources[monastery] = Monastery(self) + self.monastery = self.resources[monastery] + elif shrine: + self.resources[shrine] = Shrine(self) + self.monastery = self.resources[shrine] + else: + self.monastery = None + _roads = {i: Road(self, inn=inn==i) for i in [road_left, road_top, road_right, road_bottom] if i is not None} + _cities = {i: City(self, cathedral=cathedral==i) for i in [city_left, city_top, city_right, city_bottom] if i is not None} + _pastures = {i: Pasture(self) for i in [pasture_tl, pasture_tr, pasture_br, pasture_bl, pasture_lt, pasture_rt, pasture_rb, pasture_lb] if i is not None} + self.resources.update(_roads) + self.resources.update(_cities) + self.resources.update(_pastures) + self.placed = False + self.x = None + self.y = None + self.road_top, self.road_left, self.road_bottom, self.road_right = \ + _roads.get(road_top), _roads.get(road_left), _roads.get(road_bottom), _roads.get(road_right) + self.city_top, self.city_left, self.city_bottom, self.city_right = \ + _cities.get(city_top), _cities.get(city_left), _cities.get(city_bottom), _cities.get(city_right) + self.pasture_tl, self.pasture_tr, self.pasture_br, self.pasture_bl, self.pasture_lt, self.pasture_rt, self.pasture_rb, self.pasture_lb = \ + _pastures.get(pasture_tl), _pastures.get(pasture_tr), _pastures.get(pasture_br), _pastures.get(pasture_bl), _pastures.get(pasture_lt), _pastures.get(pasture_rt), _pastures.get(pasture_rb), _pastures.get(pasture_lb) + + def can_place(self, field, x, y): + left = field.get((x-1, y)) + top = field.get((x, y-1)) + right = field.get((x+1, y)) + bottom = field.get((x, y+1)) + if left and ((left.pasture_rt is None) != (self.pasture_lt is None) \ + or (left.pasture_rb is None) != (self.pasture_lb is None) \ + or (left.road_right is None) != (self.road_left is None) \ + or (left.city_right is None) != (self.city_left is None)): + raise ValueError('Card does not match the one to the left') + if top and ((top.pasture_bl is None) != (self.pasture_tl is None) \ + or (top.pasture_br is None) != (self.pasture_tr is None) \ + or (top.road_bottom is None) != (self.road_top is None) \ + or (top.city_bottom is None) != (self.city_top is None)): + raise ValueError('Card does not match the one to the top') + if right and ((right.pasture_lt is None) != (self.pasture_rt is None) \ + or (right.pasture_lb is None) != (self.pasture_rb is None) \ + or (right.road_left is None) != (self.road_right is None) \ + or (right.city_left is None) != (self.city_right is None)): + raise ValueError('Card does not match the one to the right') + if bottom and ((bottom.pasture_tl is None) != (self.pasture_bl is None) \ + or (bottom.pasture_tr is None) != (self.pasture_br is None) \ + or (bottom.road_top is None) != (self.road_bottom is None) \ + or (bottom.city_top is None) != (self.city_bottom is None)): + raise ValueError('Card does not match the one to the bottom') + + def rotate_cw(self): + if self.placed: + raise RuntimeError('Cant rotate an already placed time') + self.rotation = (self.rotation + 90) % 360 + self.road_top, self.road_left, self.road_bottom, self.road_right = self.road_left, self.road_bottom, self.road_right, self.road_top + self.city_top, self.city_left, self.city_bottom, self.city_right = self.city_left, self.city_bottom, self.city_right, self.city_top + self.pasture_tl, self.pasture_tr, self.pasture_rt, self.pasture_rb, self.pasture_br, self.pasture_bl, self.pasture_lb, self.pasture_lt = self.pasture_lb, self.pasture_lt, self.pasture_tl, self.pasture_tr, self.pasture_rt, self.pasture_rb, self.pasture_br, self.pasture_bl + + def rotate_ccw(self): + if self.placed: + raise RuntimeError('Cant rotate an already placed time') + self.rotation = (self.rotation + 270) % 360 + self.road_left, self.road_bottom, self.road_right, self.road_top = self.road_top, self.road_left, self.road_bottom, self.road_right + self.city_left, self.city_bottom, self.city_right, self.city_top = self.city_top, self.city_left, self.city_bottom, self.city_right + self.pasture_lb, self.pasture_lt, self.pasture_tl, self.pasture_tr, self.pasture_rt, self.pasture_rb, self.pasture_br, self.pasture_bl = self.pasture_tl, self.pasture_tr, self.pasture_rt, self.pasture_rb, self.pasture_br, self.pasture_bl, self.pasture_lb, self.pasture_lt + + def svg(self, env, standalone=True, x=0, y=0, claimable={}, highlight=False): + tmpl = env.get_template(f'carcassonne/{self.template}.svg') + rendered = tmpl.render(resources=self.resources, rotation=self.rotation, x=x*100, y=y*100, claimable=claimable) + if highlight: + rendered += f'' + if standalone: + return '\n' + rendered + '\n' + return rendered + + @staticmethod + def generate_deck(extensions=[], field=None): + # https://upload.wikimedia.org/wikipedia/commons/f/f8/Carcassonne_tiles.svg + deck = \ + [Card(monastery=1, pasture_tl=2, pasture_tr=2, pasture_br=2, pasture_bl=2, pasture_lt=2, pasture_rt=2, pasture_rb=2, pasture_lb=2, template='001') for _ in range(4)] + \ + [Card(monastery=1, road_bottom=2, pasture_tl=3, pasture_tr=3, pasture_br=3, pasture_bl=3, pasture_lt=3, pasture_rt=3, pasture_rb=3, pasture_lb=3, template='011') for _ in range(2)] + \ + [Card(city_top=2, pasture_br=1, pasture_bl=1, pasture_lt=1, pasture_rt=1, pasture_rb=1, pasture_lb=1, template='101') for _ in range(5)] + \ + [Card(road_left=1, road_right=1, pasture_tl=2, pasture_tr=2, pasture_br=3, pasture_bl=3, pasture_lt=2, pasture_rt=2, pasture_rb=3, pasture_lb=3, template='021') for _ in range(8)] + \ + [Card(road_bottom=1, road_left=1, pasture_tl=2, pasture_tr=2, pasture_br=2, pasture_bl=3, pasture_lt=2, pasture_rt=2, pasture_rb=2, pasture_lb=3, template='022') for _ in range(9)] + \ + [Card(road_bottom=4, road_left=5, road_right=6, pasture_tl=1, pasture_tr=1, pasture_br=2, pasture_bl=3, pasture_lt=1, pasture_rt=1, pasture_rb=2, pasture_lb=3, template='031') for _ in range(4)] + \ + [Card(road_bottom=5, road_left=6, road_right=7, road_top=8, pasture_tl=1, pasture_tr=2, pasture_br=3, pasture_bl=4, pasture_lt=1, pasture_rt=2, pasture_rb=3, pasture_lb=4, template='041') for _ in range(1)] + \ + [Card(city_top=2, pasture_br=1, pasture_bl=1, pasture_lt=1, pasture_rt=1, pasture_rb=1, pasture_lb=1, template='101') for _ in range(5)] + \ + [Card(road_left=3, road_right=3, city_top=4, pasture_br=2, pasture_bl=2, pasture_lt=1, pasture_rt=1, pasture_rb=2, pasture_lb=2, template='121') for _ in range(3)] + \ + [Card(road_left=3, road_bottom=3, city_top=4, pasture_br=1, pasture_bl=2, pasture_lt=1, pasture_rt=1, pasture_rb=1, pasture_lb=2, template='122') for _ in range(3)] + \ + [Card(road_right=3, road_bottom=3, city_top=4, pasture_br=2, pasture_bl=1, pasture_lt=1, pasture_rt=1, pasture_rb=2, pasture_lb=1, template='123') for _ in range(3)] + \ + [Card(road_bottom=4, road_left=5, road_right=6, city_top=7, pasture_br=2, pasture_bl=3, pasture_lt=1, pasture_rt=1, pasture_rb=2, pasture_lb=3, template='131') for _ in range(3)] + \ + [Card(city_left=3, city_right=3, pasture_tl=1, pasture_tr=1, pasture_br=2, pasture_bl=2, template='201') for _ in range(1)] + \ + [Card(city_defense=True, city_left=3, city_right=3, pasture_tl=1, pasture_tr=1, pasture_br=2, pasture_bl=2, template='202') for _ in range(2)] + \ + [Card(city_top=2, city_right=2, pasture_bl=1, pasture_br=1, pasture_lt=1, pasture_lb=1, template='203') for _ in range(3)] + \ + [Card(city_defense=True, city_top=2, city_right=2, pasture_bl=1, pasture_br=1, pasture_lt=1, pasture_lb=1, template='204') for _ in range(2)] + \ + [Card(city_top=2, city_bottom=3, pasture_lt=1, pasture_rt=1, pasture_lb=1, pasture_rb=1, template='205') for _ in range(3)] + \ + [Card(city_top=2, city_right=3, pasture_bl=1, pasture_br=1, pasture_lb=1, pasture_lt=1, template='206') for _ in range(2)] + \ + [Card(road_left=3, road_bottom=3, city_top=4, city_right=4, pasture_lt=1, pasture_lb=2, pasture_bl=2, pasture_br=1, template='221') for _ in range(2)] + \ + [Card(road_left=3, road_bottom=3, city_defense=True, city_top=4, city_right=4, pasture_lt=1, pasture_lb=2, pasture_bl=2, pasture_br=1, template='222') for _ in range(2)] + \ + [Card(city_left=1, city_top=1, city_right=1, pasture_bl=2, pasture_br=2, template='301') for _ in range(3)] + \ + [Card(city_defense=True, city_left=1, city_top=1, city_right=1, pasture_bl=2, pasture_br=2, template='302') for _ in range(1)] + \ + [Card(road_bottom=4, city_left=3, city_top=3, city_right=3, pasture_bl=1, pasture_br=2, template='311') for _ in range(1)] + \ + [Card(road_bottom=4, city_defense=True, city_left=3, city_top=3, city_right=3, pasture_bl=1, pasture_br=2, template='312') for _ in range(2)] + \ + [Card(city_defense=True, city_left=1, city_top=1, city_right=1, city_bottom=1, template='401') for _ in range(1)] + + if 'heretics' in extensions: + # https://wikicarpedia.com/index.php/File:Sheet_Cult.png + deck += [ + Card(shrine=1, road_top=2, road_bottom=3, pasture_tl=4, pasture_lt=4, pasture_lb=4, pasture_bl=4, pasture_tr=5, pasture_rt=5, pasture_rb=5, pasture_br=5, template='he-021'), + Card(shrine=1, pasture_tl=2, pasture_lt=2, pasture_lb=2, pasture_bl=2, pasture_tr=2, pasture_rt=2, pasture_rb=2, pasture_br=2, template='he-001'), + Card(shrine=1, city_top=3, road_bottom=4, pasture_lt=2, pasture_lb=2, pasture_bl=2, pasture_rt=2, pasture_rb=2, pasture_br=2, template='he-111'), + Card(shrine=1, road_bottom=3, pasture_tl=2, pasture_lt=2, pasture_lb=2, pasture_bl=2, pasture_tr=2, pasture_rt=2, pasture_rb=2, pasture_br=2, template='he-011'), + Card(shrine=1, city_top=3, pasture_lt=2, pasture_lb=2, pasture_bl=2, pasture_rt=2, pasture_rb=2, pasture_br=2, template='he-101') + ] + + if 'inns-cathedrals' in extensions: + # https://wikicarpedia.com/index.php/Inns_and_Cathedrals + center_pasture = Card(city_top=1, city_right=2, city_bottom=3, city_left=4, pasture_tl=5, template='ic-401-pasture') + center_pasture.pasture_tl = None + deck += [ + Card(road_bottom=1, road_left=1, pasture_tl=2, pasture_tr=2, pasture_br=2, pasture_bl=3, pasture_lt=2, pasture_rt=2, pasture_rb=2, pasture_lb=3, template='ic-022-inn', inn=1), + Card(road_left=1, road_right=1, pasture_tl=2, pasture_tr=2, pasture_br=3, pasture_bl=3, pasture_lt=2, pasture_rt=2, pasture_rb=3, pasture_lb=3, template='ic-021-inn', inn=1), + Card(road_bottom=4, road_left=5, road_right=6, pasture_tl=1, pasture_tr=1, pasture_br=2, pasture_bl=3, pasture_lt=1, pasture_rt=1, pasture_rb=2, pasture_lb=3, template='ic-031-inn', inn=6), + Card(monastery=4, road_left=1, road_right=5, pasture_tl=2, pasture_tr=2, pasture_br=3, pasture_bl=3, pasture_lt=2, pasture_rt=2, pasture_rb=3, pasture_lb=3, template='ic-021-monastery'), + Card(road_bottom=1, road_right=1, road_top=2, road_left=2, pasture_tl=3, pasture_tr=4, pasture_br=5, pasture_bl=4, pasture_lt=3, pasture_rt=4, pasture_rb=5, pasture_lb=4, template='ic-041'), + Card(road_bottom=3, city_top=2, city_right=2, pasture_bl=1, pasture_br=4, pasture_lt=1, pasture_lb=1, template='ic-203-road'), + Card(city_top=1, pasture_br=3, pasture_bl=3, pasture_lt=3, pasture_rt=2, pasture_rb=2, pasture_lb=3, template='ic-101-branch'), + center_pasture, + Card(road_left=5, road_right=6, city_top=7, city_bottom=8, pasture_lt=1, pasture_rt=2, pasture_rb=4, pasture_lb=3, template='ic-221-crossing'), + Card(road_bottom=3, city_top=4, pasture_br=1, pasture_bl=2, pasture_lt=2, pasture_rt=1, pasture_rb=1, pasture_lb=2, template='ic-101-road'), + Card(city_left=1, city_top=1, city_right=1, city_bottom=1, template='ic-401-cathedral', cathedral=1), + Card(city_left=1, city_top=1, city_right=1, city_bottom=1, template='ic-401-cathedral', cathedral=1), + Card(road_left=3, road_bottom=3, city_defense=True, city_top=4, city_right=4, pasture_lt=1, pasture_lb=2, pasture_bl=2, pasture_br=1, template='ic-222-inn', inn=3), + Card(road_left=3, road_bottom=3, city_top=4, pasture_br=1, pasture_bl=2, pasture_lt=1, pasture_rt=1, pasture_rb=1, pasture_lb=2, template='ic-122-inn', inn=3), + Card(road_bottom=3, city_top=2, city_left=2, pasture_br=1, pasture_bl=4, pasture_rt=1, pasture_rb=1, template='ic-203-road-inn', inn=3), + Card(city_top=3, city_right=4, city_left=2, pasture_bl=1, pasture_br=1, template='ic-301-split'), + Card(city_defense=True, city_right=2, city_left=3, city_bottom=3, pasture_tl=1, pasture_tr=1, template='ic-302'), + Card(city_defense=True, city_left=5, city_right=5, road_top=6, road_bottom=7, pasture_tl=1, pasture_tr=2, pasture_bl=3, pasture_br=4, template='ic-202-roads') + ] + + random.shuffle(deck) + + if 'river' in extensions: + # https://wikicarpedia.com/index.php/File:River_I_And_River_II_C2_Examples.png + inn = 5 if 'inns-cathedrals' in extensions else None + riverdeck = [ + RiverCard(river_bottom=True, pasture_bl=1, pasture_lb=1, pasture_lt=1, pasture_tl=1, pasture_tr=1, pasture_rt=1, pasture_rb=1, pasture_br=1, template='r2-source'), + RiverCard(river_bottom=True, river_left=True, pasture_bl=2, pasture_lb=2, pasture_lt=1, pasture_tl=1, pasture_tr=1, pasture_rt=1, pasture_rb=1, pasture_br=1, template='r2-curve'), + + RiverCard(river_bottom=True, river_right=True, city_defense=True, city_left=3, city_top=3, pasture_bl=1, pasture_rt=1, pasture_rb=2, pasture_br=2, template='r2-city'), + RiverCard(river_top=True, river_bottom=True, road_left=5, road_right=5, pasture_bl=1, pasture_lb=1, pasture_lt=2, pasture_tl=2, pasture_tr=3, pasture_rt=3, pasture_rb=4, pasture_br=4, inn=inn, template='r2-road'), + RiverCard(river_top=True, river_bottom=True, city_left=3, city_right=3, pasture_bl=1, pasture_tl=1, pasture_tr=2, pasture_br=2, template='r2-citybridge'), + RiverCard(river_bottom=True, river_left=True, pasture_bl=2, pasture_lb=2, pasture_lt=1, pasture_tl=1, pasture_tr=1, pasture_rt=1, pasture_rb=1, pasture_br=1, template='r2-curve'), + RiverCard(river_bottom=True, river_left=True, road_top=4, road_right=4, pasture_bl=2, pasture_lb=2, pasture_lt=1, pasture_tl=1, pasture_tr=3, pasture_rt=3, pasture_rb=1, pasture_br=1, template='r2-road2'), + RiverCard(river_bottom=True, river_top=True, road_left=5, city_right=6, pasture_bl=1, pasture_lb=1, pasture_lt=2, pasture_tl=2, pasture_tr=3, pasture_br=4, template='r2-roadcity'), + RiverCard(river_top=True, pasture_bl=1, pasture_lb=1, pasture_lt=1, pasture_tl=1, pasture_tr=1, pasture_rt=1, pasture_rb=1, pasture_br=1, template='r2-lake'), + RiverCard(river_left=True, river_right=True, monastery=3, pasture_bl=1, pasture_lb=1, pasture_lt=2, pasture_tl=2, pasture_tr=2, pasture_rt=2, pasture_rb=1, pasture_br=1, template='r2-monastery'), + ] + random.shuffle(riverdeck) + riverdeck.insert(0, RiverCard(river_bottom=True, city_top=3, pasture_bl=1, pasture_lb=1, pasture_lt=1, pasture_rt=2, pasture_rb=2, pasture_br=2, template='r2-lake2')) + deck += riverdeck + if field: + field._resources = {} + field._field = {} + field.place_card(0, 0, RiverCard(river_bottom=True, river_top=True, river_right=True, pasture_bl=1, pasture_lb=1, pasture_lt=1, pasture_tl=1, pasture_tr=2, pasture_rt=2, pasture_rb=3, pasture_br=3, template='r2-fork')) + + return deck + + +class RiverCard(Card): + def __init__(self, river_left=False, river_top=False, river_right=False, river_bottom=False, **kwargs): + self.river_left = river_left + self.river_top = river_top + self.river_right = river_right + self.river_bottom = river_bottom + super().__init__(**kwargs) + + def can_place(self, field, x, y): + super().can_place(field, x, y) + left = field.get((x-1, y)) + top = field.get((x, y-1)) + right = field.get((x+1, y)) + bottom = field.get((x, y+1)) + adj = [t for t in [left, top, right, bottom] if t is not None] + if len(adj) != 1: + raise ValueError('Cannot place river tile here') + if left and (not left.river_right or not self.river_left): + raise ValueError('Card does not match the one to the left') + if top and (not top.river_bottom or not self.river_top): + raise ValueError('Card does not match the one to the top') + if right and (not right.river_left or not self.river_right): + raise ValueError('Card does not match the one to the right') + if bottom and (not bottom.river_top or not self.river_bottom): + raise ValueError('Card does not match the one to the bottom') + # Check that there are no immediate 180deg turn + if self.river_left and not left: + if (top and top.river_left) or (bottom and bottom.river_left): + raise ValueError('Card placement introduces a 180 degree angle') + elif self.river_top and not top: + if (left and left.river_top) or (right and right.river_top): + raise ValueError('Card placement introduces a 180 degree angle') + elif self.river_right and not right: + if (top and top.river_right) or (bottom and bottom.river_right): + raise ValueError('Card placement introduces a 180 degree angle') + elif self.river_bottom and not bottom: + if (left and left.river_bottom) or (right and right.river_bottom): + raise ValueError('Card placement introduces a 180 degree angle') + + def rotate_cw(self): + super().rotate_cw() + self.river_top, self.river_left, self.river_bottom, self.river_right = self.river_left, self.river_bottom, self.river_right, self.river_top + + def rotate_ccw(self): + super().rotate_ccw() + self.river_left, self.river_bottom, self.river_right, self.river_top = self.river_top, self.river_left, self.river_bottom, self.river_right + + +class Resource: + + def __init__(self, card): + self.owners = set() + self.cards = set([card]) + self.joined = self + self.completed = False + self.completed_before_end = False + self.boundaries = set() + self._uuid = uuid4() + + def root(self): + a = self + while a != a.joined: + a = a.joined + self.joined = a + return a + + def uuid(self): + return self.root()._uuid + + def join(self, other): + if type(self) != type(other.joined): + raise TypeError(f'Cannot join {type(self)} with {type(other.joined)}') + a = self.root() + b = other.root() + if a.completed or b.completed: + raise RuntimeError(f'Cannot join completed resources') + a.owners.update(b.owners) + a.cards.update(b.cards) + a.boundaries.update(b.boundaries) + b.joined = a + return a + + def claim(self, player, card): + a = self.root() + if len(a.owners) > 0: + raise RuntimeError('Cannot claim already-claimed resource') + if len([1 for f in player.followers if f.resource is None]) == 0: + raise RuntimeError('Player has no followers left') + for f in player.followers: + if f.resource is None: + a.owners.add(f) + f.claim(self, card) + break + + def unclaim(self): + r = self.root() + for follower in r.owners: + follower.resource = None + r.owners = [] + + + def complete(self, game_end, players): + r = self.root() + if r.completed: + return + r.completed = True + r.completed_before_end = not game_end + owners = {} + for follower in r.owners: + owners.setdefault(follower.player.uuid, 0) + owners[follower.player.uuid] += 1 + if not game_end: + r.unclaim() + nmax = max(owners.values(), default=0) + scoring_players = [uuid for uuid, n in owners.items() if n == nmax] + for p in scoring_players: + players[p].score += r.get_score(game_end) + + def get_score(self, game_end): + return 0 + + + +class Monastery(Resource): + + def __init__(self, card): + super().__init__(card) + self._score = 0 + self._competitors = set() + + def join(self, other): + raise TypeError('Cannot join monasteries') + + def get_score(self, game_end): + for c in self._competitors: + c.unclaim() + return self._score + + def add_competitor(self, other): + if isinstance(other, Shrine): + self._competitors.add(other) + other._competitors.add(self) + + +class Shrine(Monastery): + + def __init__(self, card): + super().__init__(card) + + def join(self, other): + raise TypeError('Cannot join shrines') + + def add_competitor(self, other): + if isinstance(other, Monastery) and not isinstance(other, Shrine): + self._competitors.add(other) + other._competitors.add(self) + + +class Road(Resource): + + def __init__(self, card, inn=False): + super().__init__(card) + self._inn = inn + + def get_score(self, game_end): + r = self.root() + if r._inn: + return (2 * len(r.cards)) if not game_end else 0 + else: + return len(r.cards) + + def join(self, other): + a = self.root() + b = other.root() + joined = super().join(other) + joined._inn = a._inn or b._inn + return joined + + +class City(Resource): + + def __init__(self, card, cathedral=False): + super().__init__(card) + self._cathedral = cathedral + + def get_score(self, game_end): + r = self.root() + score = sum([2 if c.city_defense else 1 for c in r.cards]) + if r._cathedral: + return (score * 3) if not game_end else 0 + else: + return (score * 2) if not game_end else score + + def join(self, other): + a = self.root() + b = other.root() + joined = super().join(other) + joined._cathedral = a._cathedral or b._cathedral + return joined + + +class Pasture(Resource): + + def __init__(self, card): + super().__init__(card) + + def complete(self, game_end, players): + if not game_end: + return + return super().complete(game_end, players) + + def get_score(self, game_end): + if not game_end: + return 0 + r = self.root() + cities = set() + for card in r.cards: + if card.pasture_tl and card.pasture_tl.root() is self and card.city_left and card.city_left.completed_before_end: + cities.add(card.city_left.root().uuid()) + if card.pasture_tr and card.pasture_tr.root() is self and card.city_right and card.city_right.completed_before_end: + cities.add(card.city_right.root().uuid()) + if card.pasture_rt and card.pasture_rt.root() is self and card.city_top and card.city_top.completed_before_end: + cities.add(card.city_top.root().uuid()) + if card.pasture_rb and card.pasture_rb.root() is self and card.city_bottom and card.city_bottom.completed_before_end: + cities.add(card.city_bottom.root().uuid()) + if card.pasture_br and card.pasture_br.root() is self and card.city_right and card.city_right.completed_before_end: + cities.add(card.city_right.root().uuid()) + if card.pasture_bl and card.pasture_bl.root() is self and card.city_left and card.city_left.completed_before_end: + cities.add(card.city_left.root().uuid()) + if card.pasture_lb and card.pasture_lb.root() is self and card.city_bottom and card.city_bottom.completed_before_end: + cities.add(card.city_bottom.root().uuid()) + if card.pasture_lt and card.pasture_lt.root() is self and card.city_top and card.city_top.completed_before_end: + cities.add(card.city_top.root().uuid()) + return 3 * len(cities) + + +def p(r, symbol, other, players): + if not r: + return other + owners = {} + for follower in r.root().owners: + owners.setdefault(follower.player.uuid, 0) + owners[follower.player.uuid] += 1 + max_p, _ = max(owners.items(), key=lambda x: x[1], default=(None, 0)) + if max_p: + return players[max_p].color.value.ansi + symbol + '\033[0m' + return symbol + +class CarcassonneField: + + def __init__(self): + self._min_x = 0 + self._max_x = 0 + self._min_y = 0 + self._max_y = 0 + self._field = {} + self._resources = {} + self.place_card(0, 0, Card(road_left=3, road_right=3, city_top=4, pasture_br=2, pasture_bl=2, pasture_lt=1, pasture_rt=1, pasture_rb=2, pasture_lb=2, template='121')) + + def can_place_card(self, x, y, card): + if self._field.get((x, y)): + raise KeyError(f'There already is a card at ({x}, {y})') + left = self._field.get((x-1, y)) + top = self._field.get((x, y-1)) + right = self._field.get((x+1, y)) + bottom = self._field.get((x, y+1)) + # Don't enforce placement rules for the first tile + if x != 0 or y != 0: + if not left and not top and not right and not bottom: + raise KeyError(f'There is no card around ({x}, {y})') + card.can_place(self._field, x, y) + + def place_card(self, x, y, card): + self.can_place_card(x, y, card) + left = self._field.get((x-1, y)) + top = self._field.get((x, y-1)) + right = self._field.get((x+1, y)) + bottom = self._field.get((x, y+1)) + card.road_left.boundaries.add((x, y, ResourceBoundary.LEFT)) if card.road_left else None + card.road_top.boundaries.add((x, y, ResourceBoundary.TOP)) if card.road_top else None + card.road_right.boundaries.add((x, y, ResourceBoundary.RIGHT)) if card.road_right else None + card.road_bottom.boundaries.add((x, y, ResourceBoundary.BOTTOM)) if card.road_bottom else None + card.city_left.boundaries.add((x, y, ResourceBoundary.LEFT)) if card.city_left else None + card.city_top.boundaries.add((x, y, ResourceBoundary.TOP)) if card.city_top else None + card.city_right.boundaries.add((x, y, ResourceBoundary.RIGHT)) if card.city_right else None + card.city_bottom.boundaries.add((x, y, ResourceBoundary.BOTTOM)) if card.city_bottom else None + card.pasture_lt.boundaries.add((x, y, ResourceBoundary.LEFT_TOP)) if card.pasture_lt else None + card.pasture_tl.boundaries.add((x, y, ResourceBoundary.TOP_LEFT)) if card.pasture_tl else None + card.pasture_tr.boundaries.add((x, y, ResourceBoundary.TOP_RIGHT)) if card.pasture_tr else None + card.pasture_rt.boundaries.add((x, y, ResourceBoundary.RIGHT_TOP)) if card.pasture_rt else None + card.pasture_rb.boundaries.add((x, y, ResourceBoundary.RIGHT_BOTTOM)) if card.pasture_rb else None + card.pasture_br.boundaries.add((x, y, ResourceBoundary.BOTTOM_RIGHT)) if card.pasture_br else None + card.pasture_bl.boundaries.add((x, y, ResourceBoundary.BOTTOM_LEFT)) if card.pasture_bl else None + card.pasture_lb.boundaries.add((x, y, ResourceBoundary.LEFT_BOTTOM)) if card.pasture_lb else None + + if card.road_left and left and left.road_right: + j = card.road_left.join(left.road_right) + j.boundaries.discard((x, y, ResourceBoundary.LEFT)) + j.boundaries.discard((x-1, y, ResourceBoundary.RIGHT)) + if card.road_top and top and top.road_bottom: + j = card.road_top.join(top.road_bottom) + j.boundaries.discard((x, y, ResourceBoundary.TOP)) + j.boundaries.discard((x, y-1, ResourceBoundary.BOTTOM)) + if card.road_right and right and right.road_left: + j = card.road_right.join(right.road_left) + j.boundaries.discard((x, y, ResourceBoundary.RIGHT)) + j.boundaries.discard((x+1, y, ResourceBoundary.LEFT)) + if card.road_bottom and bottom and bottom.road_top: + j = card.road_bottom.join(bottom.road_top) + j.boundaries.discard((x, y, ResourceBoundary.BOTTOM)) + j.boundaries.discard((x, y+1, ResourceBoundary.TOP)) + + if card.city_left and left and left.city_right: + j = card.city_left.join(left.city_right) + j.boundaries.discard((x, y, ResourceBoundary.LEFT)) + j.boundaries.discard((x-1, y, ResourceBoundary.RIGHT)) + if card.city_top and top and top.city_bottom: + j = card.city_top.join(top.city_bottom) + j.boundaries.discard((x, y, ResourceBoundary.TOP)) + j.boundaries.discard((x, y-1, ResourceBoundary.BOTTOM)) + if card.city_right and right and right.city_left: + j = card.city_right.join(right.city_left) + j.boundaries.discard((x, y, ResourceBoundary.RIGHT)) + j.boundaries.discard((x+1, y, ResourceBoundary.LEFT)) + if card.city_bottom and bottom and bottom.city_top: + j = card.city_bottom.join(bottom.city_top) + j.boundaries.discard((x, y, ResourceBoundary.BOTTOM)) + j.boundaries.discard((x, y+1, ResourceBoundary.TOP)) + + if card.pasture_lt and left and left.pasture_rt: + j = card.pasture_lt.join(left.pasture_rt) + j.boundaries.discard((x, y, ResourceBoundary.LEFT_TOP)) + j.boundaries.discard((x-1, y, ResourceBoundary.RIGHT_TOP)) + if card.pasture_tl and top and top.pasture_bl: + j = card.pasture_tl.join(top.pasture_bl) + j.boundaries.discard((x, y, ResourceBoundary.TOP_LEFT)) + j.boundaries.discard((x, y-1, ResourceBoundary.BOTTOM_LEFT)) + if card.pasture_rt and right and right.pasture_lt: + j = card.pasture_rt.join(right.pasture_lt) + j.boundaries.discard((x, y, ResourceBoundary.RIGHT_TOP)) + j.boundaries.discard((x+1, y, ResourceBoundary.LEFT_TOP)) + if card.pasture_tr and top and top.pasture_br: + j = card.pasture_tr.join(top.pasture_br) + j.boundaries.discard((x, y, ResourceBoundary.TOP_RIGHT)) + j.boundaries.discard((x, y-1, ResourceBoundary.BOTTOM_RIGHT)) + if card.pasture_rb and right and right.pasture_lb: + j = card.pasture_rb.join(right.pasture_lb) + j.boundaries.discard((x, y, ResourceBoundary.RIGHT_BOTTOM)) + j.boundaries.discard((x+1, y, ResourceBoundary.LEFT_BOTTOM)) + if card.pasture_br and bottom and bottom.pasture_tr: + j = card.pasture_br.join(bottom.pasture_tr) + j.boundaries.discard((x, y, ResourceBoundary.BOTTOM_RIGHT)) + j.boundaries.discard((x, y+1, ResourceBoundary.TOP_RIGHT)) + if card.pasture_lb and left and left.pasture_rb: + j = card.pasture_lb.join(left.pasture_rb) + j.boundaries.discard((x, y, ResourceBoundary.LEFT_BOTTOM)) + j.boundaries.discard((x-1, y, ResourceBoundary.RIGHT_BOTTOM)) + if card.pasture_bl and bottom and bottom.pasture_tl: + j = card.pasture_bl.join(bottom.pasture_tl) + j.boundaries.discard((x, y, ResourceBoundary.BOTTOM_LEFT)) + j.boundaries.discard((x, y+1, ResourceBoundary.TOP_LEFT)) + + self._field[(x, y)] = card + card.placed = True + card.x = x + card.y = y + self._min_x = min(self._min_x, x) + self._max_x = max(self._max_x, x) + self._min_y = min(self._min_y, y) + self._max_y = max(self._max_y, y) + completed_resources = set() + for r in card.resources.values(): + r = r.root() + self._resources[r.uuid] = r + if len(r.boundaries) == 0 and not isinstance(r, Monastery): + completed_resources.add(r) + # special handling for monasteries, as they can't be joined and require diagonally adjacent tiles + if card.monastery: + card.monastery._score = len([1 for i in range(x-1, x+2) for j in range(y-1, y+2) if (i, j) in self._field]) -1 + for c in [self._field[(i, j)] for i in range(x-1, x+2) for j in range(y-1, y+2) if (i, j) in self._field]: + if c.monastery: + c.monastery._score += 1 + if card.monastery: + c.monastery.add_competitor(card.monastery) + if c.monastery._score == 9: + completed_resources.add(c.monastery) + return completed_resources + + def get_available_fields(self, card=None): + empty_fields = set() + for x, y in self._field.keys(): + for ax, ay in [(x-1,y), (x,y-1), (x+1,y), (x,y+1)]: + if card: + try: + self.can_place_card(ax, ay, card) + except: + continue + empty_fields.add((ax, ay)) + for c in self._field.keys(): + empty_fields.discard(c) + return empty_fields + + + def svg(self, env, claimable, new_card, followers, phase, controls): + empty_fields = self.get_available_fields(new_card if phase == Phase.PLACE else None) + cards = [] + empties = [] + follows = [] + for coord, card in self._field.items(): + if controls and card is new_card and phase == Phase.CLAIM: + claim = claimable + else: + claim = {} + x, y = coord + gx = x - self._min_x + 1 + gy = y - self._min_y + 1 + cards.append(card.svg(env, standalone=False, x=gx, y=gy, claimable=claim, highlight=card is new_card)) + if controls and phase == Phase.PLACE: + for x, y in empty_fields: + empties.append((x, y, (x-self._min_x+1)*100, (y-self._min_y+1)*100)) + for follower in followers: + follows.append(follower.svg(env, -self._min_x+1, -self._min_y+1)) + tmpl = env.get_template('carcassonne/field.svg') + return tmpl.render(width=self._max_x-self._min_x+3, height=self._max_y-self._min_y+3, cards=cards, empties=empties, follows=follows) + + + def print_board(self, next_card=None, players={}): + width = (self._max_x - self._min_x + 3) * 4 + 1 + height = (self._max_y - self._min_y + 3) * 4 + 1 + chrs = [[' ' for x in range(width)] for y in range(height)] + for x in range(0, width, 4): + for y in range(height): + chrs[y][x] = '│' + for y in range(0, height, 4): + for x in range(width): + chrs[y][x] = '─' if x % 4 != 0 else '┼' + items = list(self._field.items()) + if next_card: + items.append(((self._min_x-1, self._min_y-1), next_card)) + for coord, card in items: + x, y = coord + x = (x - self._min_x + 1) * 4 + 2 + y = (y - self._min_y + 1) * 4 + 2 + chrs[y-1][x] = p(card.road_top, '=', chrs[y-1][x], players) + chrs[y-1][x] = p(card.city_top, '#', chrs[y-1][x], players) + chrs[y][x-1] = p(card.road_left, '=', chrs[y][x-1], players) + chrs[y][x-1] = p(card.city_left, '#', chrs[y][x-1], players) + chrs[y][x] = p(card.monastery, 'M', chrs[y][x], players) + chrs[y][x+1] = p(card.road_right, '=', chrs[y][x+1], players) + chrs[y][x+1] = p(card.city_right, '#', chrs[y][x+1], players) + chrs[y+1][x] = p(card.road_bottom, '=', chrs[y+1][x], players) + chrs[y+1][x] = p(card.city_bottom, '#', chrs[y+1][x], players) + for line in chrs: + print(''.join(line)) + + +class Player: + + def __init__(self, uuid, i): + self.uuid = uuid + self.color = list(Color)[i] + self.score = 0 + self.followers = [Follower(self) for _ in range(7)] + + +class Follower: + + def __init__(self, player): + self.uuid = uuid4() + self.player = player + self.resource = None + self.cx = None + self.cy = None + self.lx = 50 + self.ly = 50 + + def claim(self, r, card): + if self.resource is not None: + raise RuntimeError('Follower already claimed') + self.resource = r.root() + self.cx = card.x + self.cy = card.y + self.lx = 50 + self.ly = 50 + if card.road_top is r or card.city_top is r: + self.ly = 15 + elif card.road_bottom is r or card.city_bottom is r: + self.ly = 85 + elif card.road_left is r or card.city_left is r: + self.lx = 15 + elif card.road_right is r or card.city_right is r: + self.lx = 85 + elif card.pasture_lt is r: + self.lx = 15 + self.ly = 25 + elif card.pasture_tl is r: + self.lx = 25 + self.ly = 15 + elif card.pasture_rt is r: + self.lx = 85 + self.ly = 25 + elif card.pasture_tr is r: + self.lx = 75 + self.ly = 15 + elif card.pasture_lb is r: + self.lx = 15 + self.ly = 75 + elif card.pasture_bl is r: + self.lx = 25 + self.ly = 85 + elif card.pasture_rb is r: + self.lx = 85 + self.ly = 75 + elif card.pasture_br is r: + self.lx = 75 + self.ly = 85 + + def svg(self, env, xoff, yoff): + if self.cx is None or self.cy is None or self.resource is None: + return '' + if isinstance(self.resource, Pasture): + tmpl = env.get_template(f'carcassonne/follower_pasture.svg') + else: + tmpl = env.get_template(f'carcassonne/follower.svg') + return tmpl.render(x=(self.cx+xoff)*100, y=(self.cy+yoff)*100, lx=self.lx, ly=self.ly, color=self.player.color.value.html) + + +class Carcassonne(Puzzle): + + def __init__(self, pargs = None): + super().__init__('carcassonne', self.__class__) + if not pargs: + pargs = {} + self._players = {} + self._followers = [] + self._scores = {} + self._turn_order = [] + self._turn = 0 + self._phase = Phase.PLACE + self._done = False + self._card = None + self._claimable = {} + self._completed_resources = set() + self._completed_pastures = set() + self._field = CarcassonneField() + self._urlbase = '/' + + def add_player(self, uuid: UUID) -> None: + p = Player(uuid, len(self._turn_order)) + self._players[uuid] = p + self._followers.extend(p.followers) + self._turn_order.append(uuid) + self._turn += 1 + + def get_extra_options_html(self) -> str: + return ''' +

Extensions

+ +
+ +
+ +
+''' + + def begin(self, options, urlbase): + self._urlbase = urlbase + self._deck = Card.generate_deck(options.getall('extensions'), self._field) + random.shuffle(self._turn_order) + self.next_turn() + + def _check_done(self): + if self._done or len(self._deck) > 0 or self._card is not None: + return + self._done = True + for r in self._field._resources.values(): + if r.completed: + continue + r.complete(True, self._players) + self._completed_pastures = set() + for r in self._field._resources.values(): + r = r.root() + if r.completed and len(r.owners) > 0: + self._completed_pastures.add(r.uuid()) + + def next_turn(self): + self._claimable = {} + for r in self._completed_resources: + r.complete(False, self._players) + self._completed_resources.clear() + self._phase = Phase.PLACE + self._turn = (self._turn + 1) % len(self._turn_order) + try: + # Discard cards that can't be placed + placeable = False + discarded = [] + card = None + while not placeable: + if card is not None: + discarded.insert(0, card) + card = self._deck.pop() + for _ in range(4): + if len(self._field.get_available_fields(card)) > 0: + placeable = True + break + card.rotate_cw() + # Return the discarded cards to the deck + self._deck.extend(discarded) + self._card = card + except IndexError: + self._card = None + self._check_done() + + def process_action(self, puuid, action) -> None: + try: + act = action.get('action', None) + if puuid != self._turn_order[self._turn]: + return + if act == 'Rotate CW': + if self._phase != Phase.PLACE: + return + self._card.rotate_cw() + if act == 'Rotate CCW': + if self._phase != Phase.PLACE: + return + self._card.rotate_ccw() + if act == 'Place Tile': + if self._phase != Phase.PLACE: + return + x = int(action.get('x')) + y = int(action.get('y')) + self._completed_resources = self._field.place_card(x, y, self._card) + self._claimable = {r.uuid(): r.root() for r in self._card.resources.values() if len(r.root().owners) == 0} + if len(self._claimable) > 0 and len([f for f in self._players[puuid].followers if f.resource is None]) > 0: + self._phase = Phase.CLAIM + else: + self.next_turn() + if act == 'Claim': + if self._phase != Phase.CLAIM: + return + cuuid = action.get('claim') + if cuuid: + cuuid = UUID(hex=cuuid) + self._claimable[cuuid].claim(self._players[puuid], self._card) + self.next_turn() + except BaseException as e: + print(e) + + def serialize(self, player): + # todo + return {} + + def render(self, env, game, player, duration): + show_controls = player.uuid == self._turn_order[self._turn] and not self._done + field = self._field.svg(env, self._claimable, self._card, self._followers, self._phase, show_controls) + card = '' + claimable = '' + if self._card and self._phase == Phase.PLACE: + card = self._card.svg(env, standalone=True) + if self._claimable and self._phase == Phase.CLAIM: + claimable = '\n ' + '\n '.join([f'' for r in self._claimable.values()]) + tmpl = env.get_template('carcassonne/carcassonne.html.j2') + return tmpl.render(field=field, card=card, claimable=claimable, phase='place' if self._phase == Phase.PLACE else 'claim', + players=self._players.values(), me=self._players[player.uuid], + current_player=self._players[self._turn_order[self._turn]], + game=game, done=self._done, + completed_pastures=self._completed_pastures, deck=self._deck, + baseurl=self._urlbase) + + @property + def human_name(self) -> str: + return 'Carcassonne' + + @property + def scores(self): + return sorted( + [{'player': uuid, 'score': player.score} for uuid, player in self._players.items()], + key=lambda x: x['score'], reverse=True) + + @property + def is_completed(self) -> bool: + return self._done + + +Carcassonne() diff --git a/webgames/game.py b/webgames/game.py new file mode 100644 index 0000000..3052827 --- /dev/null +++ b/webgames/game.py @@ -0,0 +1,105 @@ +from typing import Any, Dict, List + +from enum import Enum +from uuid import uuid4, UUID +from datetime import datetime + +from webgames.puzzle import Puzzle + +from webgames.sudoku_puzzle import Sudoku +from webgames.set_puzzle import SetPuzzle +from webgames.carcassonne import Carcassonne +from webgames.player import Player +from webgames.human import HumanID + + +class GameState(Enum): + LOBBY = 0 + RUNNING = 1 + FINISHED = 2 + + +class Game: + + def __init__(self, puzzle: str, puzzle_args: Dict[str, Any] = None) -> None: + self._uuid: UUID = uuid4() + self._human_id: HumanId = HumanID(self.uuid) + self._players: Dict[UUID, Player] = {} + self._puzzle: Puzzle = Puzzle.create(puzzle, puzzle_args) + self._start: datetime = None + self._end: datetime = None + self._state = GameState.LOBBY + + def join(self, player: Player) -> None: + if self._state != GameState.LOBBY: + raise RuntimeError(f'Can\'t join a game in {self._state} state') + player.game = self + self._players[player.uuid] = player + self._puzzle.add_player(player.uuid) + + def render(self, env, player, duration): + return self._puzzle.render(env, self, player, duration) + + def get_extra_options_html(self) -> str: + return self._puzzle.get_extra_options_html() + + def begin(self, options, urlbase) -> None: + if self._state != GameState.LOBBY: + raise RuntimeError(f'Can\'t start a game in {self._state} state') + self._start = datetime.utcnow() + self._state = GameState.RUNNING + self._puzzle.begin(options, urlbase) + + def conclude(self) -> None: + if self._state == GameState.FINISHED: + raise RuntimeError(f'Can\'t start a game in {self._state} state') + if not self._start: + self._start = datetime.utcnow() + self._end = datetime.utcnow() + self._state = GameState.FINISHED + + def process_action(self, player: UUID, action) -> None: + if self._state != GameState.RUNNING: + raise RuntimeError(f'Can\'t process a game action in {self._state} state') + self._puzzle.process_action(player, action) + + def serialize_puzzle(self, player: UUID) -> None: + if self._state != GameState.RUNNING: + raise RuntimeError(f'Can\'t fetch a player puzzle in {self._state} state') + return self._puzzle.serialize(player) + + @property + def uuid(self) -> UUID: + return self._uuid + + @property + def human_id(self) -> HumanID: + return self._human_id + + @property + def puzzle_name(self): + return self._puzzle.human_name + + @property + def puzzle(self): + return self._puzzle + + @property + def players(self) -> Dict[UUID, Player]: + return self._players + + @property + def scoreboard(self) -> List[Any]: + return self._puzzle.scores + + @property + def state(self) -> GameState: + return self._state + + @property + def start(self) -> datetime: + return self._start + + @property + def end(self) -> datetime: + return self._end diff --git a/webgames/human.py b/webgames/human.py new file mode 100644 index 0000000..8251240 --- /dev/null +++ b/webgames/human.py @@ -0,0 +1,57 @@ + +import uuid +import hashlib +from functools import reduce + + +class HumanID: + + _CODE = { + 0: 'A', + 1: 'B', + 2: 'F', + 3: 'H', + 4: 'J', + 5: 'K', + 6: 'L', + 7: 'M', + 8: 'P', + 9: 'Q', + 10: 'R', + 11: 'S', + 12: 'T', + 13: 'U', + 14: 'X', + 15: 'Z' + } + + _resolver = {} + + def __init__(self, longid: uuid.UUID = None) -> None: + if not longid: + longid = uuid.uuid4() + sha = hashlib.sha1(longid.bytes).digest() + e = reduce(lambda a,b: a^b, [e for e in sha[::2]], 0) + o = reduce(lambda a,b: a^b, [o for o in sha[1::2]], 0) + nibbles = [(e & 0xf0) >> 4, e & 0x0f, (o & 0xf0) >> 4, o & 0x0f] + self._human_id = ''.join([HumanID._CODE[i] for i in nibbles]) + self._uuid = longid + HumanID._resolver[self._human_id] = self._uuid + + def __del__(self) -> None: + HumanID._resolver[self._human_id] + + @classmethod + def resolve(cls, human_id: str) -> uuid.UUID: + return cls._resolver[human_id] + + def __str__(self) -> str: + return self._human_id + + @property + def human_id(self) -> str: + return self._human_id + + @property + def uuid(self) -> uuid.UUID: + return self._uuid diff --git a/webgames/player.py b/webgames/player.py new file mode 100644 index 0000000..ed5f61c --- /dev/null +++ b/webgames/player.py @@ -0,0 +1,23 @@ +from uuid import uuid4, UUID + +from namesgenerator import get_random_name + + +class Player: + + def __init__(self, name: str = None) -> None: + if name is None: + name = get_random_name(' ').title() + self._uuid: UUID = uuid4() + self._name: str = name + + @property + def uuid(self) -> UUID: + return self._uuid + + @property + def name(self) -> str: + return self._name + + def __repr__(self) -> str: + return f'Player({self._name})' diff --git a/webgames/puzzle.py b/webgames/puzzle.py new file mode 100644 index 0000000..4fbb7ca --- /dev/null +++ b/webgames/puzzle.py @@ -0,0 +1,50 @@ + +from typing import Any, Dict + +import abc + +from uuid import UUID + + +_PUZZLES = {} + + +class Puzzle(abc.ABC): + + def __init__(self, name, clazz) -> None: + global _PUZZLES + _PUZZLES[name] = clazz + + def get_extra_options_html(self) -> str: + return '' + + def add_player(self, uuid: UUID) -> None: + pass + + def begin(self, options: Dict[str, Any], urlbase: str) -> None: + pass + + def process_action(self, player, action) -> None: + pass + + def serialize(self, player): + pass + + def render(self, env, game, player, duration): + pass + + @property + def human_name(self) -> str: + pass + + @property + def scores(self): + pass + + @property + def is_completed(self) -> bool: + pass + + @staticmethod + def create(puzzle: str, args) -> 'Puzzle': + return _PUZZLES[puzzle](args) diff --git a/webgames/set_puzzle.py b/webgames/set_puzzle.py new file mode 100644 index 0000000..11d1dc6 --- /dev/null +++ b/webgames/set_puzzle.py @@ -0,0 +1,206 @@ +import random +import enum +from uuid import UUID + +from webgames.puzzle import Puzzle + + +class _Color: + + def __init__(self, name, html): + self.name = name + self.html = html + + +class Infill(enum.Enum): + NONE = 1 + SEMI = 2 + FULL = 3 + + +class Count(enum.Enum): + ONE = 1 + TWO = 2 + THREE = 3 + + +class Shape(enum.Enum): + RECTANGLE = 1 + TILDE = 2 + ELLIPSE = 3 + + +class Color(enum.Enum): + RED = _Color('red', '#ff0000') + GREEN = _Color('green', '#00ff00') + BLUE = _Color('blue', '#0000ff') + + +class Card: + + def __init__(self, infill, count, shape, color): + self.infill = infill + self.count = count + self.shape = shape + self.color = color + + def __repr__(self) -> str: + return f'' + + def serialize(self): + return { + 'infill': self.infill.name, + 'count': self.count.name, + 'shape': self.shape.name, + 'color': self.color.name + } + + @staticmethod + def validate_set(c1, c2, c3): + return sum([ + len(x) == 1 or len(x) == 3 + for x in [ + { c1.infill, c2.infill, c3.infill }, + { c1.count, c2.count, c3.count }, + { c1.shape, c2.shape, c3.shape }, + { c1.color, c2.color, c3.color } + ] + ]) == 4 + + @staticmethod + def generate_deck(): + deck = [ + Card(infill, count, shape, color) + for infill in Infill + for count in Count + for shape in Shape + for color in Color + ] + random.shuffle(deck) + return deck + + +class SetPuzzle(Puzzle): + + def __init__(self, pargs = None): + super().__init__('set', self.__class__) + if not pargs: + pargs = {} + self._players = {} + self._scores = {} + self._deck = Card.generate_deck() + self._playing_field = [] + self._draw_votes = set() + self._draw_majority = 0 + self._done = False + self._draw(9) + self._urlbase = '/' + + def get_extra_options_html(self) -> str: + return '' + + def add_player(self, uuid: UUID) -> None: + self._players[uuid] = [] + self._scores[uuid] = 0 + self._draw_majority = int(-(-(len(self._players)+0.5)//2)) + + def begin(self, options, urlbase): + self._urlbase = urlbase + + def _draw(self, n: int = 3, append=True): + ret = [] + for i in range(n): + try: + card = self._deck.pop() + except IndexError: + break + ret.append(card) + if append: + self._playing_field.append(card) + self._draw_votes.clear() + return ret + + def _check_done(self): + if self._done or len(self._deck) > 0: + return + for i1 in range(0, len(self._playing_field)-2): + for i2 in range(i1+1, len(self._playing_field)-1): + for i3 in range(i2+1, len(self._playing_field)): + if Card.validate_set(self._playing_field[i1], self._playing_field[i2], self._playing_field[i3]): + # There is still a valid set - not done yet + return + self._done = True + + def process_action(self, player, action) -> None: + act = action.get('action', None) + if act == 'Draw 3': + self._draw_votes.add(player) + if len(self._draw_votes) >= self._draw_majority: + self._draw() + self._check_done() + elif act == 'Set!': + ci = [int(x) for x in action.getall('set')] + cards = [self._playing_field[i] for i in ci] + if len(cards) != 3: + return + if Card.validate_set(*cards): + self._scores[player] += 3 + new = self._draw(append=False) + for card, i in zip(cards, ci): + try: + self._playing_field[i] = new.pop() + except IndexError: + self._playing_field.remove(card) + self._players[player].append(card) + self._check_done() + else: + self._scores[player] -= 1 + self._draw_votes.clear() + + def serialize(self, player): + return { + 'field': [x.serialize() for x in self._playing_field], + 'players': sorted([{ + 'name': x, + 'score': self._scores[x], + 'cards': [y.serialize() for y in self._players[x][-3:]] + } for x in self._players.keys()], key=lambda x: x['score'], reverse=True), + 'deck': len(self._deck), + 'draw': { + 'votes': len(self._draw_votes), + 'majority': self._draw_majority + }, + 'done': self._done + } + + def render(self, env, game, player, duration): + tmpl = env.get_template('cwa/set.html.j2') + return tmpl.render(player=player, + game=game, + duration=duration, + cards=self._playing_field, + deck=self._deck, + lastset={k: v[-3:] for k, v in self._players.items()}, + draw_votes=len(self._draw_votes), + draw_majority=self._draw_majority, + Color=Color, + Shape=Shape, + Infill=Infill, + baseurl=self._urlbase) + + @property + def human_name(self) -> str: + return 'Set' + + @property + def scores(self): + return sorted( + [{'player': k, 'score': v} for k, v in self._scores.items()], + key=lambda x: x['score'], reverse=True) + + @property + def is_completed(self) -> bool: + return self._done + + +SetPuzzle() diff --git a/webgames/sudoku_puzzle.py b/webgames/sudoku_puzzle.py new file mode 100644 index 0000000..6351ec4 --- /dev/null +++ b/webgames/sudoku_puzzle.py @@ -0,0 +1,169 @@ +from typing import Any, Dict + +from uuid import UUID + +from sudokugen.solver import solve, NoSolution +from sudoku_manager import Sudoku as SudokuManager + +from webgames.puzzle import Puzzle + +class Sudoku: + + def __init__(self, difficulty: int = 2) -> None: + self._solved = False + self.grid = SudokuManager.generate_grid(difficulty) + self.original = [[col is not None for col in row] for row in self.grid] + + def put(self, row: int, col: int, value: int) -> None: + if self.original[row-1][col-1]: + raise KeyError(f'Cell ({row},{col}) is part of the original grid') + if value == 0: + value = None + if value is not None and value not in range(10): + raise ValueError(f'Don\'t understand value {value}') + self.grid[row-1][col-1] = value + + def get(self, row: int, col: int) -> int: + return self.grid[row-1][col-1] + + def begin(self): + pass + + def make_instance(self) -> 'Sudoku': + grid = [[None for i in range(9)] for i in range(9)] + for row in range(9): + for col in range(9): + if self.original[row][col]: + grid[row][col] = self.grid[row][col] + else: + grid[row][col] = None + s = Sudoku() + s.grid = grid + s.original = self.original + return s + + def process_action(self, action: Dict[str, Any]) -> None: + field = action['sudoku-field'] + row = int(field[:1]) + col = int(field[1:]) + number = action['number'] + if number == 'Delete': + self.put(row, col, None) + else: + self.put(row, col, int(number)) + + def serialize(self) -> Dict[str, Any]: + return { + 'grid': self.grid, + 'original': self.original, + 'filled': self.filled, + 'solved': self.solved + } + + @property + def filled(self) -> bool: + if self._solved: + return True + return len([1 for row in self.grid for col in row if col is None]) == 0 + + @property + def solved(self) -> bool: + if not self.filled: + return False + if self._solved: + return True + try: + solve(self.grid) + self._solved = True + except NoSolution: + return False + return True + + @property + def progress(self) -> float: + n = len([1 for row in self.original for col in row if not col]) + i = len([1 for row in self.grid for col in row if col is None]) + return 1 - i/n + + +class SudokuPuzzle(Puzzle): + + def __init__(self, pargs = None): + super().__init__('sudoku', self.__class__) + if not pargs: + pargs = {} + self._puzzles: Dict[UUID, Sudoku] = {} + self._scores: List[Any] = [] + self._urlbase = '/' + + def begin(self, options: Dict[str, Any], urlbase): + difficulty = int(options.get('sudoku-input-difficulty', '2')) + self._urlbase = urlbase + self._puzzle = Sudoku(difficulty) + for p in self._puzzles.keys(): + self._puzzles[p] = self._puzzle.make_instance() + + def get_extra_options_html(self) -> str: + return ''' + + +''' + + def add_player(self, uuid: UUID) -> None: + self._puzzles[uuid] = None + + def process_action(self, player, action) -> None: + cell = action.get('sudoku-field') + row, col = int(cell[0]), int(cell[1]) + v = action.get('number') + if v == 'Delete': + value = None + else: + value = int(v) + + puzzle = self._puzzles[player] + if puzzle.solved: + raise RuntimeError(f'Can\'t process a game action for a solved puzzle') + puzzle.process_action(action) + if puzzle.solved: + duration = datetime.utcnow() - self._start + self._scores.append({ + 'player': player, + 'duration': duration + }) + + def serialize(self, player): + puzzle = self._puzzles[player] + return puzzle.serialize() + + def render(self, env, game, player, duration): + puzzle = self._puzzles[player.uuid] + tmpl = env.get_template('cwa/sudoku.html.j2') + return tmpl.render(player=player, + game=game, + puzzle=puzzle, + duration=duration, + baseurl=self._urlbase) + + + @property + def human_name(self) -> str: + return 'Sudoku' + + @property + def puzzles(self): + return self._puzzles + + @property + def scores(self): + return self._scores + + @property + def is_completed(self) -> bool: + for x in self._puzzles.values(): + if not x.solved: + return False + return True + + +SudokuPuzzle()