├── stc ├── debug.js ├── favicon.ico ├── spinner.gif ├── favicon-inactive.ico ├── org.css ├── reset.js ├── alias.js ├── register.js ├── settings.js ├── buildstats.js ├── ratings.js ├── index.js └── player.js ├── .gitignore ├── config ├── config-example.json ├── crontab.example └── apache.conf ├── src ├── app.psgi ├── buildings.pm ├── app.fcgi ├── DB │ ├── Secret.pm │ ├── Connection.pm │ ├── Chat.pm │ ├── EditLink.pm │ ├── Validation.pm │ ├── UserValidate.pm │ ├── Settings.pm │ ├── UserInfo.pm │ └── AddGames.pm ├── cults.pm ├── Server │ ├── Logout.pm │ ├── Request.pm │ ├── Template.pm │ ├── Security.pm │ ├── Settings.pm │ ├── UserInfo.pm │ ├── Results.pm │ ├── Server.pm │ ├── Session.pm │ ├── Login.pm │ ├── SaveGame.pm │ ├── ListGames.pm │ ├── Plan.pm │ ├── Alias.pm │ ├── JoinGame.pm │ ├── PasswordReset.pm │ ├── Chat.pm │ ├── Router.pm │ ├── EditGame.pm │ ├── Register.pm │ └── Map.pm ├── Util │ ├── SiteConfig.pm │ ├── NaturalCmp.pm │ ├── ServerUtil.pm │ ├── PasswordQuality.pm │ ├── CryptUtil.pm │ ├── Watchdog.pm │ └── PageGenerator.pm ├── abort-idle-games.pl ├── add-games-batch.pl ├── genratings.pl ├── add-map-vp-variant.pl ├── listgames.pl ├── indexgame.pl ├── Analyze │ ├── BuildStats │ │ ├── invert-builds.pl │ │ └── builds.pl │ └── EloVpPredictor.pm ├── drop-game.pl ├── tracker.pl ├── Game │ ├── Factions │ │ ├── Darklings.pm │ │ ├── Giants.pm │ │ ├── Engineers.pm │ │ ├── Nomads.pm │ │ ├── Auren.pm │ │ ├── Dwarves.pm │ │ ├── Fakirs.pm │ │ ├── Witches.pm │ │ ├── Mermaids.pm │ │ ├── Swarmlings.pm │ │ ├── Cultists.pm │ │ ├── Halflings.pm │ │ ├── Alchemists.pm │ │ ├── Chaosmagicians.pm │ │ ├── Icemaidens.pm │ │ └── Yetis.pm │ ├── Events.pm │ └── Factions.pm ├── update-active-time.pl ├── diffgamelist.pl ├── drop-idle-players.pl ├── income.pm ├── towns.pm └── ledger.pm ├── pages ├── content │ ├── usage.pl │ ├── game.pl │ ├── faction.pl │ ├── joingame.pl │ ├── blog.pl │ ├── map.pl │ ├── changes.pl │ ├── edit.pl │ ├── about.pl │ ├── buildstats.pl │ ├── login.pl │ ├── reset.pl │ ├── index.pl │ ├── mapedit.pl │ ├── alias.pl │ ├── player.pl │ ├── register.pl │ ├── settings.pl │ ├── stats.pl │ └── forcedreset.pl └── layout │ ├── topbar.html │ └── sidebar.html ├── robots.txt ├── README.org ├── test └── testgame3.txt └── LICENSE /stc/debug.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.orig 2 | *.rej 3 | *.diff 4 | -------------------------------------------------------------------------------- /stc/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsnell/terra-mystica/HEAD/stc/favicon.ico -------------------------------------------------------------------------------- /stc/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsnell/terra-mystica/HEAD/stc/spinner.gif -------------------------------------------------------------------------------- /stc/favicon-inactive.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsnell/terra-mystica/HEAD/stc/favicon-inactive.ico -------------------------------------------------------------------------------- /config/config-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "terra.snellman.net", 3 | "email_domain": "terra.snellman.net", 4 | "site_admin_username": "jsnell", 5 | "blacklist": ["deedeebyrd"] 6 | } 7 | -------------------------------------------------------------------------------- /src/app.psgi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | # -*- mode: perl -*- 3 | 4 | BEGIN { 5 | use File::Basename; 6 | push @INC, dirname $0; 7 | } 8 | 9 | use JSON; 10 | 11 | use Server::Router; 12 | 13 | \&Server::Router::psgi_router; 14 | -------------------------------------------------------------------------------- /src/buildings.pm: -------------------------------------------------------------------------------- 1 | package terra_mystica; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | use Game::Constants; 7 | 8 | sub alias_building { 9 | my $type = shift; 10 | 11 | return $building_aliases{$type} // $type; 12 | } 13 | 14 | 1; 15 | -------------------------------------------------------------------------------- /pages/content/usage.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js" ], 4 | title => "User's Guide", 5 | content => do { 6 | open my $data, "<", "../usage.html"; 7 | read_then_close($data); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /pages/content/game.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'topbar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/faction.js", 5 | "/stc/game.js"], 6 | title => 'Public View', 7 | content => read_then_close(*DATA) 8 | } 9 | 10 | __DATA__ 11 | -------------------------------------------------------------------------------- /pages/content/faction.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'topbar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/faction.js", 5 | "/stc/game.js"], 6 | title => 'Faction View', 7 | content => read_then_close(*DATA) 8 | } 9 | 10 | __DATA__ 11 | -------------------------------------------------------------------------------- /stc/org.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: Georgia, Serif 3 | } 4 | 5 | div#content { 6 | margin-left: 5ex; 7 | } 8 | 9 | p { 10 | max-width: 65ex; 11 | } 12 | 13 | li { 14 | max-width: 65ex; 15 | } 16 | 17 | pre { 18 | font-family: monospace; 19 | max-width: 80ex; 20 | } 21 | -------------------------------------------------------------------------------- /src/app.fcgi: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | TM_CONFIG=${TM_CONFIG:-$(dirname $0)/config.json} 4 | cd $(dirname $0) 5 | if [ "$ENV" = "devel" ]; then 6 | EXTRA_WATCH_DIRS="-R /home/jsnell/sites/terra/git/src/" 7 | fi 8 | 9 | set -x 10 | 11 | export PERL_HASH_SEED=0 12 | exec plackup -s FCGI -r $EXTRA_WATCH_DIRS --access-log=/dev/null 13 | -------------------------------------------------------------------------------- /src/DB/Secret.pm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | 5 | package DB::Secret; 6 | use Exporter::Easy (EXPORT => [ 'get_secret' ]); 7 | 8 | use DB::Connection; 9 | 10 | sub get_secret { 11 | my $dbh = shift || get_db_connection; 12 | 13 | $dbh->selectrow_array("select secret, shared_iv from secret limit 1"); 14 | } 15 | 16 | 1; 17 | -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /app/ 3 | Disallow: /cgi-bin/ 4 | Disallow: /about/ 5 | Disallow: /alias/ 6 | Disallow: /edit/ 7 | Disallow: /faction/ 8 | Disallow: /game/ 9 | Disallow: /joingame/ 10 | Disallow: /login/ 11 | Disallow: /map/ 12 | Disallow: /newgame/ 13 | Disallow: /register/ 14 | Disallow: /reset/ 15 | Disallow: /settings/ 16 | Allow: / 17 | -------------------------------------------------------------------------------- /src/cults.pm: -------------------------------------------------------------------------------- 1 | package terra_mystica; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | use Game::Constants; 7 | 8 | sub setup_cults { 9 | my %cults = (); 10 | for my $cult (@cults) { 11 | $cults{"${cult}1"} = { gain => { $cult => 3 } }; 12 | $cults{"${cult}$_"} = { gain => { $cult => 2 } } for 2..4; 13 | } 14 | 15 | \%cults; 16 | } 17 | 18 | 1; 19 | 20 | -------------------------------------------------------------------------------- /pages/content/joingame.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/joingame.js"], 5 | title => 'Open Games', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |
11 | 12 |
13 | 14 | 17 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | #+TITLE: README 2 | #+AUTHOR: Juho Snellman 3 | #+EMAIL: jsnell@iki.fi 4 | #+STYLE: 5 | 6 | A play-by-email / play-by-web moderator for the boardgame Terra Mystica. 7 | 8 | - Full rules enforcement, automatic email notifications, command language + simple UI 9 | - Written in Perl and JavaScript 10 | - Uses PostgreSQL as the backing storage 11 | 12 | -------------------------------------------------------------------------------- /src/DB/Connection.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package DB::Connection; 4 | use Exporter::Easy ( 5 | EXPORT => [ qw(get_db_connection) ], 6 | OK => [ qw(get_db_connection) ], 7 | ); 8 | 9 | use DBI; 10 | 11 | sub get_db_connection { 12 | DBI->connect("dbi:Pg:dbname=terra-mystica", '', '', 13 | { AutoCommit => 1, RaiseError => 1, pg_enable_utf8 => 1, client_encoding => 'UTF-8' }); 14 | } 15 | 16 | 1; 17 | 18 | -------------------------------------------------------------------------------- /test/testgame3.txt: -------------------------------------------------------------------------------- 1 | # Random setup 2 | delete bon6 3 | 4 | score score6,score4,score5,score1,score8,score2 5 | 6 | setup Alchemists 7 | setup Witches 8 | setup chaosmagicians 9 | setup Cultists 10 | setup Fakirs 11 | 12 | Alchemists: Build G5 13 | Witches: build F4 14 | Cultists: Build e6 15 | Fakirs: build f3. build g4 16 | Cultists: build b2 17 | Witches: build E9 18 | Alchemists: build C1 19 | Chaosmagicians: build D7 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/DB/Chat.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package DB::Chat; 4 | use Exporter::Easy (EXPORT => [ 'insert_chat_message']); 5 | 6 | sub insert_chat_message { 7 | my ($dbh, $game_id, $faction_name, $message, $posted_on_turn) = @_; 8 | 9 | $dbh->do( 10 | "insert into chat_message (faction, game, message, posted_on_turn) values (?, ?, ?, ?)", 11 | {}, 12 | $faction_name, 13 | $game_id, 14 | $message, 15 | $posted_on_turn); 16 | } 17 | 18 | 1; 19 | -------------------------------------------------------------------------------- /src/Server/Logout.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Logout; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | method handle($q) { 12 | $self->no_cache(); 13 | $self->set_header("Set-Cookie", "csrf-token=; Path=/"); 14 | $self->set_header("Set-Cookie", "session-username=; Path=/"); 15 | $self->set_header("Set-Cookie", "session-token=; Path=/; HttpOnly"); 16 | $self->redirect("/"); 17 | } 18 | 19 | 1; 20 | 21 | -------------------------------------------------------------------------------- /src/Util/SiteConfig.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Util::SiteConfig; 4 | use Exporter::Easy (EXPORT => [ '%config' ]); 5 | 6 | use JSON; 7 | 8 | use vars qw(%config); 9 | 10 | { 11 | die "TM_CONFIG not provided" if !$ENV{'TM_CONFIG'}; 12 | my $config_fn = $ENV{'TM_CONFIG'}; 13 | open(my $config_fh, "<:encoding(UTF-8)", $config_fn) 14 | or die("Can't open configuration file \"$config_fn\": $!\n"); 15 | local $/; 16 | %config = %{JSON->new->decode(<$config_fh>)}; 17 | close $config_fh; 18 | } 19 | 20 | 1; 21 | -------------------------------------------------------------------------------- /src/abort-idle-games.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | 5 | use File::Basename; 6 | 7 | BEGIN { push @INC, dirname $0 } 8 | 9 | use DB::Connection; 10 | 11 | my $dbh = get_db_connection; 12 | 13 | $dbh->do("begin"); 14 | my $count = $dbh->do("update game set aborted=true, finished=true where last_update < now() - interval '4 weeks' and not finished", 15 | {}, 16 | ()); 17 | if ($count > 0) { 18 | print STDERR "Aborting $count games\n"; 19 | } 20 | $dbh->do("commit"); 21 | -------------------------------------------------------------------------------- /pages/content/blog.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js" ], 4 | title => 'Blog', 5 | content => read_then_close(*DATA) 6 | } 7 | 8 | __DATA__ 9 |

10 | This is an index of blog posts on this site, or occasionally on 11 | Terra Mystica more generally. 12 |

13 | 14 |
15 | 16 | 21 | -------------------------------------------------------------------------------- /src/add-games-batch.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | no indirect; 5 | 6 | BEGIN { push @INC, "$ENV{PWD}/src/"; } 7 | 8 | use JSON; 9 | use DB::Connection; 10 | use DB::AddGames; 11 | 12 | my $dbh = get_db_connection; 13 | my $desc = decode_json join '', <>; 14 | 15 | { 16 | validate $dbh, $desc; 17 | print "Validation passed. Really create games [yn]?\n"; 18 | my $query = ; 19 | chomp $query; 20 | if ($query eq 'y') { 21 | make_games $dbh, $desc; 22 | } else { 23 | print "Canceling\n"; 24 | exit 1; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Util/NaturalCmp.pm: -------------------------------------------------------------------------------- 1 | package Util::NaturalCmp; 2 | use Exporter::Easy (EXPORT => [ 'natural_cmp' ]); 3 | 4 | sub split_nums { 5 | local $_ = shift; 6 | /(\d+|\D+)/g; 7 | } 8 | 9 | sub natural_cmp { 10 | my @a = split_nums shift; 11 | my @b = split_nums shift; 12 | 13 | while (@a and @b) { 14 | my $a = shift @a; 15 | my $b = shift @b; 16 | next if $a eq $b; 17 | 18 | if ($a =~ /\d/ and $b =~ /\d/) { 19 | return $a <=> $b; 20 | } else { 21 | return $a cmp $b; 22 | } 23 | } 24 | 25 | return @a <=> @b; 26 | } 27 | 28 | 1; 29 | -------------------------------------------------------------------------------- /pages/content/map.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/faction.js", 5 | "/stc/game.js", 6 | "/stc/map.js"], 7 | title => 'Map Viewer', 8 | content => read_then_close(*DATA) 9 | } 10 | 11 | __DATA__ 12 |
13 | 14 |

ID

15 |
16 | 17 |

Map

18 |
19 | 22 |
23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Server/Request.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | no indirect; 3 | 4 | package Server::Request; 5 | 6 | use parent qw(CGI::PSGI); 7 | 8 | use Encode; 9 | 10 | sub new { 11 | my ($class, @args) = @_; 12 | my $self = $class->SUPER::new(@args); 13 | $self; 14 | } 15 | 16 | sub param { 17 | my ($q, @args) = @_; 18 | if (wantarray) { 19 | map { decode('utf-8', $_) } $q->SUPER::multi_param(@args); 20 | } else { 21 | decode('utf-8', scalar $q->SUPER::param(@args)); 22 | } 23 | } 24 | 25 | sub param_or_die { 26 | my ($q, $param) = @_; 27 | $q->param($param) // die "Required parameter '$param' undefined\n"; 28 | }; 29 | 30 | 1; 31 | 32 | 33 | -------------------------------------------------------------------------------- /config/crontab.example: -------------------------------------------------------------------------------- 1 | 28 4 * * * (cd /home/jsnell/sites/terra/git && perl src/abort-idle-games.pl) 2 | 25 * * * * (cd /home/jsnell/sites/terra/git && perl src/drop-idle-players.pl) 3 | 30 * * * * (cd /home/jsnell/sites/terra/git && perl src/genstats.pl > www-prod/data/stats.json.new && mv www-prod/data/stats.json.new www-prod/data/stats.json) 4 | 35 * * * * (cd /home/jsnell/sites/terra/git && perl src/genratings.pl > www-prod/data/ratings.json.new && mv www-prod/data/ratings.json.new www-prod/data/ratings.json) 5 | */10 * * * * (cd /home/jsnell/sites/terra/git && perl src/update-active-time.pl) 6 | 25 4 * * * psql terra-mystica -c "delete from to_validate where created_at < now() - '2 weeks'::interval;" 7 | -------------------------------------------------------------------------------- /pages/content/changes.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js" ], 4 | title => 'Changelog', 5 | content => read_then_close(*DATA) 6 | } 7 | 8 | __DATA__ 9 |

10 | This is a list of larger user-visible changes, feature additions, etc. 11 | For a tedious list including minor bugfixes and cosmetic changes, see the 12 | version control logs. 13 |

14 | 15 |
16 | 17 | 22 | -------------------------------------------------------------------------------- /pages/content/edit.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/edit.js"], 5 | title => 'Game Administration', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |
11 | 12 |
13 |
14 |
15 |
16 | 17 |

Commands

18 |
19 | 20 |
21 | 22 |
23 | 24 | 25 | 26 | 28 | 31 | -------------------------------------------------------------------------------- /pages/content/about.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js"], 4 | title => 'About', 5 | content => read_then_close(*DATA) 6 | } 7 | 8 | __DATA__ 9 |

10 | This is an unofficial automated moderator tool 11 | for Terra 12 | Mystica, a game by Helge Ostertag and Jens 13 | Drögemüller published by Feuerland Spiele. 14 |

15 | 16 |

17 | Terra Mystica is a trademark of Frank Heeren (Feuerland 18 | Spiele). 19 |

20 | 21 |

22 | The site was created 23 | by Juho 24 | Snellman. The source code is available on 25 | github. 26 |

27 | -------------------------------------------------------------------------------- /src/Util/ServerUtil.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Util::ServerUtil; 4 | use Exporter::Easy (EXPORT => [ 'log_with_request' ]); 5 | 6 | use JSON; 7 | use POSIX; 8 | 9 | sub log_with_request { 10 | my ($q, $error) = @_; 11 | chomp $error; 12 | 13 | my $timestamp = asctime localtime; 14 | chomp $timestamp; 15 | 16 | my $ip = $q->remote_host(); 17 | my $username = $q->cookie('session-username') // ''; 18 | my $params = eval { 19 | my @vars = grep { !/password/ } $q->param; 20 | my %params = map { ($_ => scalar $q->param($_)) } @vars; 21 | encode_json \%params 22 | }; 23 | 24 | my $path_info = $q->path_info(); 25 | print STDERR "[$timestamp] ip=$ip path=$path_info username=$username\nparams=$params\nERROR: $error\n", '-'x60, "\n"; 26 | } 27 | 28 | 1; 29 | -------------------------------------------------------------------------------- /src/Server/Template.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Template; 4 | 5 | use JSON; 6 | use Moose; 7 | use Server::Server; 8 | use Method::Signatures::Simple; 9 | 10 | extends 'Server::Server'; 11 | 12 | use DB::Connection; 13 | use Server::Session; 14 | use Util::PageGenerator; 15 | 16 | method handle($q, $suffix) { 17 | ensure_csrf_cookie $q, $self; 18 | 19 | if (!$suffix) { 20 | $suffix = 'index'; 21 | } 22 | 23 | $suffix =~ s{/.*}{}g; 24 | 25 | my $dbh = get_db_connection; 26 | my $params = { 27 | username => username_from_session_token($dbh, 28 | $q->cookie('session-token') // '') // '', 29 | }; 30 | 31 | $self->no_cache(); 32 | $self->output_html(generate_page '..', $suffix, $params); 33 | } 34 | 35 | 1; 36 | 37 | -------------------------------------------------------------------------------- /src/genratings.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -wl 2 | 3 | use strict; 4 | 5 | use JSON; 6 | use POSIX; 7 | 8 | BEGIN { push @INC, "$ENV{PWD}/src/"; } 9 | 10 | use DB::Connection; 11 | 12 | use Analyze::ELO; 13 | use Analyze::RatingData; 14 | 15 | my $dbh = get_db_connection; 16 | my $rating_data = read_rating_data $dbh; 17 | my $elo = compute_elo $rating_data; 18 | 19 | # pprint_elo_results $elo; 20 | 21 | $elo->{timestamp} = POSIX::strftime "%Y-%m-%d %H:%M UTC", gmtime time; 22 | 23 | $dbh = get_db_connection; 24 | $dbh->do("begin"); 25 | $dbh->do("delete from player_ratings"); 26 | for my $player (keys %{$elo->{players}}) { 27 | my $rating = $elo->{players}{$player}{score}; 28 | $dbh->do("insert into player_ratings (player, rating) values (?, ?)", 29 | {}, 30 | $player, int $rating); 31 | } 32 | $dbh->do("commit"); 33 | 34 | print encode_json $elo; 35 | 36 | -------------------------------------------------------------------------------- /pages/content/buildstats.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/faction.js", 5 | "/stc/game.js", 6 | "/stc/map.js", 7 | "/stc/buildstats.js"], 8 | title => 'Faction Heat Map', 9 | content => read_then_close(*DATA) 10 | } 11 | 12 | __DATA__ 13 |
14 | 15 |
16 | Map: 17 | Faction: 18 | Rank: 19 | Games: 20 |
21 | 22 |
23 | 24 | Browser not supported. 25 | 26 |
27 | 28 | 29 | 32 | -------------------------------------------------------------------------------- /src/Util/PasswordQuality.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Util::PasswordQuality; 4 | use Exporter::Easy (EXPORT => [ 'password_too_weak' ]); 5 | 6 | use Data::Password::Common qw(found); 7 | 8 | sub password_too_weak { 9 | my ($username, $password) = @_; 10 | 11 | if (lc $password eq lc $username or 12 | (length $username >= 5 and 13 | ($password =~ /^\Q$username\E/i or 14 | $password =~ /\Q$username\E$/i))) { 15 | return "password is too similar to username\n"; 16 | } 17 | 18 | if (found $password or 19 | lc $password eq 'terra' or 20 | lc $password eq 'terramystica' or 21 | lc $password eq 'snellman') { 22 | return "password is too common\n"; 23 | } 24 | 25 | if (length $password < 6) { 26 | return "password is too short (must be at least 6 characters)\n"; 27 | } 28 | 29 | return 0; 30 | } 31 | 32 | 33 | 1; 34 | -------------------------------------------------------------------------------- /src/DB/EditLink.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package DB::EditLink; 4 | use Exporter::Easy (EXPORT => [ 'edit_link_for_faction' ]); 5 | 6 | use Crypt::CBC; 7 | use DB::Secret; 8 | use Encode; 9 | 10 | sub edit_link_for_faction { 11 | my ($dbh, $id, $faction_name) = @_; 12 | 13 | my ($secret, $iv) = get_secret $dbh; 14 | 15 | my ($game, $game_secret) = ($id =~ /(.*)_(.*)/g); 16 | $game_secret = pack "h*", $game_secret; 17 | my $cipher = Crypt::CBC->new(-key => $secret, 18 | -blocksize => 8, 19 | -iv => $iv, 20 | -add_header => 0, 21 | -cipher => 'Blowfish'); 22 | my $data = "$game_secret" ^ encode('iso-8859-1', "$faction_name"); 23 | my $token = unpack 'h*', $cipher->encrypt($data); 24 | 25 | return "/faction/$game/".($faction_name)."/$token"; 26 | } 27 | 28 | 1; 29 | -------------------------------------------------------------------------------- /src/Server/Security.pm: -------------------------------------------------------------------------------- 1 | package Server::Security; 2 | use Exporter::Easy (EXPORT => [ 'ensure_user_may_view_game', 'get_write_id_for_user' ]); 3 | 4 | sub get_write_id_for_user { 5 | my ($dbh, $username, $read_id, $faction_name) = @_; 6 | 7 | if (!defined $username) { 8 | die "Not logged in\n"; 9 | } 10 | 11 | if (!$dbh->selectrow_array("select count(*) from game_role where game=? and faction=? and faction_player=?", 12 | {}, 13 | $read_id, 14 | $faction_name, 15 | $username)) { 16 | die "You ($username) don't appear to be the player controlling $faction_name in game $read_id.\n"; 17 | } 18 | 19 | return $dbh->selectrow_array("select write_id from game where id=?", 20 | {}, 21 | $read_id); 22 | } 23 | 24 | 1; 25 | -------------------------------------------------------------------------------- /src/add-map-vp-variant.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -lw 2 | 3 | use strict; 4 | no indirect; 5 | 6 | use DBI; 7 | use Digest::SHA qw(sha1_hex); 8 | 9 | BEGIN { push @INC, "$ENV{PWD}/src/"; } 10 | 11 | use DB::Connection; 12 | use Game::Constants; 13 | 14 | my $dbh = get_db_connection; 15 | 16 | my ($mapid, $vp_variant) = @ARGV; 17 | die "No game id supplied\n" if !$mapid; 18 | die "No vp variant supplied\n" if !$vp_variant; 19 | 20 | if (!$Game::Constants::vp_setups{$vp_variant}) { 21 | die "Bad vp variant '$vp_variant'\n" 22 | } 23 | 24 | my ($map_str) = $dbh->selectrow_array("select terrain from map_variant where id=?", {}, $mapid); 25 | die "Bad map id: '$mapid'" if !$map_str; 26 | 27 | my $base_map = [ split /\s+/, $map_str ]; 28 | 29 | my $id = sha1_hex "$map_str $vp_variant"; 30 | 31 | $dbh->do("begin"); 32 | 33 | $dbh->do("insert into map_variant (id, terrain, vp_variant) values (?, ?, ?)", 34 | {}, 35 | $id, $map_str, $vp_variant); 36 | 37 | $dbh->do("commit"); 38 | -------------------------------------------------------------------------------- /src/listgames.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -wl 2 | 3 | package terra_mystica; 4 | 5 | use strict; 6 | 7 | use JSON; 8 | use List::Util qw(max); 9 | use Time::HiRes qw(time); 10 | 11 | our $target; 12 | 13 | BEGIN { 14 | $target = shift @ARGV; 15 | unshift @INC, "$target/lib/"; 16 | } 17 | 18 | use tracker; 19 | 20 | BEGIN { 21 | eval { 22 | require 'db.pm'; 23 | require 'game.pm'; 24 | }; if ($@) { 25 | require 'DB/Connection.pm'; 26 | DB::Connection->import(); 27 | require 'DB/Game.pm'; 28 | DB::Game->import(); 29 | } 30 | } 31 | 32 | sub print_json { 33 | my $data = shift; 34 | my $out = encode_json $data; 35 | 36 | print $out; 37 | } 38 | 39 | 40 | my $dbh = get_db_connection; 41 | 42 | while (<>) { 43 | my $query = $_; 44 | chomp $query; 45 | my $begin = time; 46 | my $res = get_user_game_list $dbh, split /\s+/, $query; 47 | 48 | $| = 1; 49 | print_json { res => $res, cost => time - $begin }; 50 | } 51 | -------------------------------------------------------------------------------- /src/indexgame.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | 5 | use File::Slurp qw(read_file); 6 | use File::Basename; 7 | use JSON; 8 | 9 | BEGIN { push @INC, dirname $0 } 10 | 11 | use DB::Connection; 12 | use DB::Game; 13 | use DB::SaveGame; 14 | use tracker; 15 | 16 | my $dbh = get_db_connection; 17 | 18 | sub evaluate_and_index_game { 19 | my ($read_id, $write_id, $timestamp) = @_; 20 | 21 | print "$read_id\n"; 22 | 23 | begin_game_transaction $dbh, $read_id; 24 | 25 | my ($prefix_content, $orig_content) = 26 | get_game_content $dbh, $read_id, $write_id; 27 | 28 | my $res = evaluate_and_save $dbh, $read_id, $write_id, $prefix_content, $orig_content; 29 | 30 | finish_game_transaction $dbh; 31 | } 32 | 33 | my $pattern = shift; 34 | die "Usage: $0 pattern\n" if !$pattern; 35 | 36 | my $games = $dbh->selectall_arrayref("select id, write_id, extract(epoch from last_update) from game where id like ?", 37 | {}, 38 | $pattern); 39 | 40 | for (@{$games}) { 41 | evaluate_and_index_game @{$_}; 42 | } 43 | 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2012-2019 Juho Snellman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /pages/layout/topbar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Terra Mystica 5 | 6 | 7 | 10 | { 11 | for my $path (@scripts) { 12 | $OUT .= "\n" 13 | } 14 | } 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /pages/content/login.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js" ], 4 | title => 'Login', 5 | content => read_then_close(*DATA) 6 | } 7 | 8 | __DATA__ 9 |
10 | 21 |
22 | 23 |
Username 24 |
Password 25 |
26 |
27 |
28 | 29 |

30 | Trouble logging in? Register an account 31 | or reset your password. 32 | -------------------------------------------------------------------------------- /pages/content/reset.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/reset.js" ], 5 | title => 'Reset Password', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |

11 |
12 | 13 |
Email Address 14 |
New Password 15 |
New Password (again) 16 |
17 |
18 |
19 |
20 | 31 | -------------------------------------------------------------------------------- /src/Server/Settings.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Settings; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use Digest::SHA qw(sha1_hex); 12 | 13 | use DB::Connection; 14 | use DB::Settings; 15 | use Server::Session; 16 | 17 | method handle($q) { 18 | verify_csrf_cookie_or_die $q, $self; 19 | $self->no_cache(); 20 | 21 | my $dbh = get_db_connection; 22 | 23 | my $username = username_from_session_token($dbh, 24 | $q->cookie('session-token') // ''); 25 | 26 | if (!$username) { 27 | $self->output_json({ 28 | error => ["Login required"], 29 | link => "/login/#required", 30 | }); 31 | return; 32 | } 33 | 34 | my $res; 35 | 36 | eval { 37 | if ($q->param('save')) { 38 | save_user_settings $dbh, $username, $q; 39 | } 40 | 41 | $res = fetch_user_settings $dbh, $username; 42 | $res->{error} = []; 43 | }; if ($@) { 44 | print STDERR "Settings error: $@\n"; 45 | $res = { error => [ $@ ] }; 46 | } 47 | 48 | $self->output_json($res); 49 | } 50 | 51 | 1; 52 | -------------------------------------------------------------------------------- /stc/reset.js: -------------------------------------------------------------------------------- 1 | function resetPassword() { 2 | $("error").innerHTML = ""; 3 | $("validate").style.display = "none"; 4 | 5 | try { 6 | var fields = ["email", "password"]; 7 | var error = ""; 8 | 9 | fields.each(function (field) { 10 | $(field).style.backgroundColor = "#fff"; 11 | }); 12 | 13 | fields.each(function (field) { 14 | if ($(field).value == "") { 15 | $(field).style.backgroundColor = "#fbb"; 16 | error += "Field " + field + " must be non-empty
"; 17 | } 18 | }); 19 | 20 | if (error != "") { 21 | throw error; 22 | } 23 | 24 | $("userinfo").request({ 25 | method:"post", 26 | onSuccess: function(transport) { 27 | state = transport.responseText.evalJSON(); 28 | if (state.error.length) { 29 | $("error").innerHTML = state.error.join("
"); 30 | } else { 31 | $("validate").style.display = "block"; 32 | $("usage").style.display = "none"; 33 | } 34 | } 35 | }); 36 | } catch (e) { 37 | handleException(e); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Analyze/BuildStats/invert-builds.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -lw 2 | 3 | package terra_mystica; 4 | 5 | use JSON; 6 | no indirect; 7 | 8 | BEGIN { push @INC, "$ENV{PWD}/src/"; } 9 | 10 | use DB::Connection; 11 | use DB::Game; 12 | use tracker; 13 | 14 | my $dbh = get_db_connection; 15 | 16 | my %by_map = (); 17 | 18 | while (<>) { 19 | my $game = decode_json $_; 20 | my $metadata = get_game_metadata $dbh, $game->{id}; 21 | my $by_map = ($by_map{$metadata->{map_variant} // '126fe960806d587c78546b30f1a90853b1ada468'} //= {}); 22 | if (!defined $by_map->{base_map}) { 23 | $by_map->{base_map} = setup_map $metadata->{base_map} || \@base_map; 24 | } 25 | for my $faction (keys %{$game->{factions}}) { 26 | my $by_faction = ($by_map->{factions}{$faction} //= {}); 27 | my $rank = $game->{factions}{$faction}{rank}; 28 | if (!defined $rank) { 29 | print STDERR "$faction ", encode_json $game; 30 | } 31 | for my $build (@{$game->{factions}{$faction}{builds}}) { 32 | $by_faction->{all}{build}{uc $build}++; 33 | $by_faction->{$rank}{build}{uc $build}++; 34 | } 35 | $by_faction->{all}{games}++; 36 | $by_faction->{$rank}{games}++; 37 | } 38 | } 39 | 40 | print encode_json \%by_map; 41 | -------------------------------------------------------------------------------- /src/Util/CryptUtil.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Util::CryptUtil; 4 | use Exporter::Easy (EXPORT => [ 'encrypt_validation_token', 5 | 'read_urandom_string_base64', 6 | 'decrypt_validation_token' ]); 7 | 8 | use Crypt::CBC; 9 | use Crypt::Eksblowfish::Bcrypt qw(en_base64 de_base64); 10 | use Digest::SHA qw(sha1_base64); 11 | 12 | sub decrypt_validation_token { 13 | my ($secret, $token) = @_; 14 | 15 | my $cipher = Crypt::CBC->new(-key => $secret, 16 | -blocksize => 8, 17 | -header => 'randomiv', 18 | -cipher => 'Blowfish'); 19 | my $data = $cipher->decrypt(de_base64 $token); 20 | my @data = split /\t/, $data; 21 | 22 | my $token_csum = pop @data; 23 | my $expect_csum = sha1_base64 join "\t", @data; 24 | 25 | if ($token_csum ne $expect_csum) { 26 | die "Checksum mismatch: $expect_csum $token_csum\n"; 27 | } 28 | 29 | (@data, $token_csum); 30 | } 31 | 32 | sub read_urandom_string_base64 { 33 | my $chars = shift; 34 | 35 | open my $f, "do("begin"); 15 | 16 | my $id = shift; 17 | die "No game id supplied" if !defined $id; 18 | 19 | print "Events ", $dbh->do("delete from game_events where game=?", {}, $id); 20 | print "Notes ", $dbh->do("delete from game_note where game=?", {}, $id); 21 | print "Chat metadata ", $dbh->do("delete from chat_read where game=?", {}, $id); 22 | print "Chat messages ", $dbh->do("delete from chat_message where game=?", {}, $id); 23 | print "Game options ", $dbh->do("delete from game_options where game=?", {}, $id); 24 | print "Game roles ", $dbh->do("delete from game_role where game=?", {}, $id); 25 | print "Game players ", $dbh->do("delete from game_player where game=?", {}, $id); 26 | print "Time tracking ", $dbh->do("delete from game_active_time where game=?", {}, $id); 27 | print "Game ", $dbh->do("delete from game where id=?", {}, $id); 28 | 29 | my $response; 30 | 31 | do { 32 | print("ok [yn]?"); 33 | $response = <>; 34 | chomp $response; 35 | } until $response =~ /^[yn]$/; 36 | 37 | if ($response eq 'y') { 38 | $dbh->do("commit"); 39 | } else { 40 | print "Aborting."; 41 | } 42 | -------------------------------------------------------------------------------- /pages/content/index.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/index.js" ], 5 | title => 'Online Terra Mystica', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 | 11 | 15 | 16 |

Your Active / Recently Finished Games

17 |
18 | 19 |

Games you Administrate

20 |
21 | 22 |
23 | 24 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/DB/Validation.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package DB::Validation; 4 | use Exporter::Easy (EXPORT => [ 'insert_to_validate', 'fetch_validate_payload' ]); 5 | 6 | use Bytes::Random::Secure qw(random_string_from); 7 | use JSON; 8 | 9 | sub insert_to_validate { 10 | my ($dbh, $payload) = @_; 11 | 12 | my $random = Bytes::Random::Secure->new( 13 | Bits => 512, 14 | NonBlocking => 1, 15 | ); 16 | my $token = $random->string_from('ABCDEFGHIJKLMNOPQRSTUVXYZ'. 17 | 'abcdefghijklmnopqrstuvxyz'. 18 | '0123456789', 19 | 16); 20 | 21 | $dbh->do( 22 | "insert into to_validate (token, payload) values (?, ?)", 23 | {}, 24 | $token, encode_json $payload); 25 | 26 | $token; 27 | } 28 | 29 | sub fetch_validate_payload { 30 | my ($dbh, $token) = @_; 31 | 32 | my @payload = $dbh->selectrow_array( 33 | "select payload from to_validate where token=?", 34 | { Slice => {} }, 35 | $token); 36 | 37 | if (!@payload) { 38 | die "Invalid validation token\n"; 39 | } 40 | 41 | $dbh->do("update to_validate set executed=true where token=?", 42 | {}, 43 | $token); 44 | 45 | decode_json $payload[0]; 46 | } 47 | 48 | 1; 49 | -------------------------------------------------------------------------------- /src/DB/UserValidate.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package DB::UserValidate; 4 | use Exporter::Easy (EXPORT => [ 'check_email_is_registered', 5 | 'check_username_is_registered' ]); 6 | 7 | sub check_email_is_registered { 8 | my ($dbh, $address) = @_; 9 | 10 | my ($username) = 11 | $dbh->selectrow_array("select player from email where address=lower(?) and validated=true", 12 | {}, 13 | $address); 14 | 15 | if (!defined $username) { 16 | die "Sorry. Adding unregistered or unvalidated email addresses to games is no longer supported. Please ask your players to register on the site, or to add an alias for their new email address on the settings page.\n"; 17 | } 18 | 19 | $username; 20 | } 21 | 22 | sub check_username_is_registered { 23 | my ($dbh, $username) = @_; 24 | 25 | # XXX: primary address support 26 | my ($address, $actual_username) = 27 | $dbh->selectrow_array("select address, player from email where lower(player)=lower(?) and is_primary=true", 28 | {}, 29 | $username); 30 | 31 | if (!defined $address) { 32 | die "There is no account with the username '$username'.\n"; 33 | } 34 | 35 | ($actual_username, $address); 36 | } 37 | 38 | 1; 39 | -------------------------------------------------------------------------------- /src/tracker.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -wl 2 | 3 | package terra_mystica; 4 | 5 | use strict; 6 | 7 | use JSON; 8 | use List::Util qw(max); 9 | use Time::HiRes qw(time); 10 | 11 | our $target; 12 | 13 | BEGIN { 14 | $target = shift @ARGV; 15 | unshift @INC, "$target/lib/"; 16 | } 17 | 18 | use tracker; 19 | 20 | BEGIN { 21 | eval { 22 | require 'db.pm'; 23 | require 'game.pm'; 24 | }; if ($@) { 25 | require 'DB/Connection.pm'; 26 | DB::Connection->import(); 27 | require 'DB/Game.pm'; 28 | DB::Game->import(); 29 | } 30 | } 31 | 32 | sub print_json { 33 | my $data = shift; 34 | my $out = encode_json $data; 35 | 36 | print $out; 37 | } 38 | 39 | 40 | my $dbh = get_db_connection; 41 | 42 | while (<>) { 43 | my $id = $_; 44 | chomp $id; 45 | my @rows = get_game_commands $dbh, $id; 46 | my $begin = time; 47 | 48 | # @rows = @rows[0..(min $ENV{MAX_ROW}, scalar(@rows)-1)]; 49 | 50 | my $res = evaluate_game { 51 | rows => [ @rows ], 52 | faction_info => get_game_factions($dbh, $id), 53 | players => get_game_players($dbh, $id), 54 | metadata => get_game_metadata($dbh, $id), 55 | }; 56 | $res->{cost} = time - $begin; 57 | $| = 1; 58 | print_json $res; 59 | if (@{$res->{error}}) { 60 | print STDERR "$target $id: ERROR: $_" for @{$res->{error}}; 61 | } 62 | } 63 | 64 | -------------------------------------------------------------------------------- /stc/alias.js: -------------------------------------------------------------------------------- 1 | function register() { 2 | $("error").innerHTML = ""; 3 | $("validate").style.display = "none"; 4 | 5 | try { 6 | var fields = ["email"]; 7 | var error = ""; 8 | 9 | fields.each(function (field) { 10 | $(field).style.backgroundColor = "#fff"; 11 | }); 12 | 13 | fields.each(function (field) { 14 | if ($(field).value == "") { 15 | $(field).style.backgroundColor = "#fbb"; 16 | error += "Field " + field + " must be non-empty
"; 17 | } 18 | }); 19 | 20 | if (error != "") { 21 | throw error; 22 | } 23 | 24 | $("csrf-token").value = getCSRFToken(); 25 | $("userinfo").request({ 26 | method:"post", 27 | onFailure: function() { 28 | $("error").innerHTML = "An unknown error occured"; 29 | }, 30 | onSuccess: function(transport) { 31 | state = transport.responseText.evalJSON(); 32 | if (state.error.length) { 33 | $("error").innerHTML = state.error.join("
"); 34 | } else { 35 | $("validate").style.display = "block"; 36 | $("usage").style.display = "none"; 37 | } 38 | } 39 | }); 40 | } catch (e) { 41 | handleException(e); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Game/Factions/Darklings.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Darklings; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $darklings => { 7 | C => 15, W => 1, P => 1, P1 => 5, P2 => 7, 8 | WATER => 1, EARTH => 1, 9 | color => 'black', 10 | display => "Darklings", 11 | faction_board_id => 12, 12 | ship => { 13 | level => 0, max_level => 3, 14 | advance_cost => { C => 4, P => 1 }, 15 | advance_gain => [ { VP => 2 }, 16 | { VP => 3 }, 17 | { VP => 4 } ], 18 | }, 19 | dig => { 20 | level => 0, max_level => 0, 21 | cost => [ { P => 1 } ], 22 | gain => [ { SPADE => 1, VP => 2 } ], 23 | }, 24 | buildings => { 25 | D => { advance_cost => { W => 1, C => 2 }, 26 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 27 | TP => { advance_cost => { W => 2, C => 3 }, 28 | income => { C => [ 0, 2, 4, 6, 8 ], 29 | PW => [ 0, 1, 2, 4, 6 ] } }, 30 | TE => { advance_cost => { W => 2, C => 5 }, 31 | income => { P => [ 0, 1, 2, 3 ] } }, 32 | SH => { advance_cost => { W => 4, C => 6 }, 33 | advance_gain => [ { CONVERT_W_TO_P => 3 } ], 34 | income => { PW => [ 0, 2 ] } }, 35 | SA => { advance_cost => { W => 4, C => 10 }, 36 | income => { P => [ 0, 2 ] } }, 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /pages/content/mapedit.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/faction.js", 5 | "/stc/game.js", 6 | "/stc/map.js"], 7 | title => 'Map Editor', 8 | content => read_then_close(*DATA) 9 | } 10 | 11 | __DATA__ 12 |
13 | 14 |

ID

15 |
16 | 17 |

Map

18 |
19 | 22 |
23 | 24 |

Data

25 |
26 | 28 |
29 |
30 | 31 | 32 |
33 | 34 | 35 |

Faction Info (finished)

36 | 37 |

38 | Deviation from expected score, based on performance of other 39 | players, and adjusted for estimated player skill. 40 |

41 | 42 |
43 | 44 |

Faction Info (started)

45 | 46 |

47 | Frequency of faction being picked by player count. 48 |

49 | 50 |
51 | 52 | 53 | 54 |

Games

55 |
56 | -------------------------------------------------------------------------------- /src/Util/Watchdog.pm: -------------------------------------------------------------------------------- 1 | package Util::Watchdog; 2 | use Exporter::Easy (EXPORT => [ 'with_watchdog', 'feed_watchdog' ]); 3 | 4 | use Devel::StackTrace; 5 | 6 | sub with_watchdog { 7 | my $alarm_triggered = 0; 8 | my ($timeout, $fun) = @_; 9 | 10 | local $SIG{ALRM} = sub { 11 | $alarm_triggered = 1; 12 | my $trace = Devel::StackTrace->new; 13 | for my $frame ($trace->frames()) { 14 | print STDERR " ", $frame->as_string(), "\n"; 15 | last if $frame->subroutine() eq 'Util::Watchdog::with_watchdog'; 16 | } 17 | 18 | print "Request timed out\n"; 19 | # Treat a watchdog timeout as a fatal error, rather than an 20 | # catchable exception. DBD::Pg is not async signal safe, and 21 | # gets in a permanently corrupted state if we unwind in the 22 | # middle of a DB operation. That instance of the code will 23 | # then not work again, producing user-visible errors until the 24 | # service is manually restarted. So just quit now, and let 25 | # the server process restart. 26 | exit 1; 27 | }; 28 | 29 | eval { 30 | alarm $timeout; 31 | $fun->(); 32 | }; 33 | my $err = $@; 34 | 35 | alarm 0; 36 | 37 | if ($err) { 38 | die $err; 39 | } 40 | } 41 | 42 | sub feed_watchdog { 43 | my ($timeout) = @_; 44 | 45 | die "Can't feed watchdog -- not inside with_watchdog\n" if !$SIG{ALRM}; 46 | 47 | alarm $timeout; 48 | } 49 | 50 | 1; 51 | -------------------------------------------------------------------------------- /src/Game/Factions/Giants.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Giants; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $giants => { 7 | C => 15, W => 3, P1 => 5, P2 => 7, 8 | FIRE => 1, AIR => 1, color => 'red', 9 | display => "Giants", 10 | faction_board_id => 4, 11 | ship => { 12 | level => 0, max_level => 3, 13 | advance_cost => { C => 4, P => 1 }, 14 | advance_gain => [ { VP => 2 }, 15 | { VP => 3 }, 16 | { VP => 4 } ], 17 | }, 18 | dig => { 19 | level => 0, max_level => 2, 20 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 21 | advance_cost => { W => 2, C => 5, P => 1 }, 22 | advance_gain => [ { VP => 6 }, 23 | { VP => 6 } ], 24 | }, 25 | buildings => { 26 | D => { advance_cost => { W => 1, C => 2 }, 27 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 28 | TP => { advance_cost => { W => 2, C => 3 }, 29 | income => { C => [ 0, 2, 4, 6, 8 ], 30 | PW => [ 0, 1, 2, 4, 6] } }, 31 | TE => { advance_cost => { W => 2, C => 5 }, 32 | income => { P => [ 0, 1, 2, 3 ] } }, 33 | SH => { advance_cost => { W => 4, C => 6 }, 34 | advance_gain => [ { ACTG => 1 } ], 35 | income => { PW => [ 0, 4 ] } }, 36 | SA => { advance_cost => { W => 4, C => 6 }, 37 | income => { P => [ 0, 1 ] } }, 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /pages/layout/sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {$head_title} 5 | 6 | 7 | 10 | { 11 | for my $path (@scripts) { 12 | $OUT .= "\n" 13 | } 14 | } 15 | 16 | 17 | 18 | 19 | 20 |
21 | 25 | 31 |
32 | 33 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Game/Factions/Engineers.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Engineers; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $engineers => { 7 | C => 10, W => 2, P1 => 3, P2 => 9, color => 'gray', 8 | display => "Engineers", 9 | faction_board_id => 8, 10 | ship => { 11 | level => 0, max_level => 3, 12 | advance_cost => { C => 4, P => 1 }, 13 | advance_gain => [ { VP => 2 }, 14 | { VP => 3 }, 15 | { VP => 4 } ], 16 | }, 17 | dig => { 18 | level => 0, max_level => 2, 19 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 20 | advance_cost => { W => 2, C => 5, P => 1 }, 21 | advance_gain => [ { VP => 6 }, 22 | { VP => 6 } ], 23 | }, 24 | ACTE => 1, 25 | buildings => { 26 | D => { advance_cost => { W => 1, C => 1 }, 27 | income => { W => [ 0, 1, 2, 2, 3, 4, 4, 5, 6 ] } }, 28 | TP => { advance_cost => { W => 1, C => 2 }, 29 | income => { C => [ 0, 2, 4, 6, 8 ], 30 | PW => [ 0, 1, 2, 4, 6 ] } }, 31 | TE => { advance_cost => { W => 1, C => 4 }, 32 | income => { P => [ 0, 1, 1, 2 ], 33 | PW => [ 0, 0, 5, 5 ] } }, 34 | SH => { advance_cost => { W => 3, C => 6 }, 35 | income => { PW => [ 0, 2 ] } }, 36 | SA => { advance_cost => { W => 3, C => 6 }, 37 | income => { P => [ 0, 1 ] } }, 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/Game/Factions/Nomads.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Nomads; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $nomads => { 7 | C => 15, W => 2, P1 => 5, P2 => 7, 8 | FIRE => 1, EARTH => 1, color => 'yellow', 9 | display => "Nomads", 10 | faction_board_id => 2, 11 | ship => { 12 | level => 0, max_level => 3, 13 | advance_cost => { C => 4, P => 1 }, 14 | advance_gain => [ { VP => 2 }, 15 | { VP => 3 }, 16 | { VP => 4 } ], 17 | }, 18 | dig => { 19 | level => 0, max_level => 2, 20 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 21 | advance_cost => { W => 2, C => 5, P => 1 }, 22 | advance_gain => [ { VP => 6 }, 23 | { VP => 6 } ], 24 | }, 25 | buildings => { 26 | D => { advance_cost => { W => 1, C => 2 }, 27 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 28 | TP => { advance_cost => { W => 2, C => 3 }, 29 | income => { C => [ 0, 2, 4, 7, 11 ], 30 | PW => [ 0, 1, 2, 3, 4 ] } }, 31 | TE => { advance_cost => { W => 2, C => 5 }, 32 | income => { P => [ 0, 1, 2, 3 ] } }, 33 | SH => { advance_cost => { W => 4, C => 8 }, 34 | advance_gain => [ { ACTN => 1 } ], 35 | income => { PW => [ 0, 2 ] } }, 36 | SA => { advance_cost => { W => 4, C => 6 }, 37 | income => { P => [ 0, 1 ] } }, 38 | } 39 | }; 40 | 41 | -------------------------------------------------------------------------------- /src/Game/Factions/Auren.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Auren; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $auren => { 7 | C => 15, W => 3, P1 => 5, P2 => 7, 8 | WATER => 1, AIR => 1, 9 | color => 'green', 10 | display => "Auren", 11 | faction_board_id => 13, 12 | ship => { 13 | level => 0, max_level => 3, 14 | advance_cost => { C => 4, P => 1 }, 15 | advance_gain => [ { VP => 2 }, 16 | { VP => 3 }, 17 | { VP => 4 } ], 18 | }, 19 | dig => { 20 | level => 0, max_level => 2, 21 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 22 | advance_cost => { W => 2, C => 5, P => 1 }, 23 | advance_gain => [ { VP => 6 }, 24 | { VP => 6 } ], 25 | }, 26 | buildings => { 27 | D => { advance_cost => { W => 1, C => 2 }, 28 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 29 | TP => { advance_cost => { W => 2, C => 3 }, 30 | income => { C => [ 0, 2, 4, 6, 8 ], 31 | PW => [ 0, 1, 2, 4, 6 ] } }, 32 | TE => { advance_cost => { W => 2, C => 5 }, 33 | income => { P => [ 0, 1, 2, 3 ] } }, 34 | SH => { advance_cost => { W => 4, C => 6 }, 35 | advance_gain => [ { ACTA => 1, GAIN_FAVOR => 1 } ], 36 | income => { PW => [ 0, 2 ] } }, 37 | SA => { advance_cost => { W => 4, C => 8 }, 38 | income => { P => [ 0, 1 ] } }, 39 | } 40 | }; 41 | 42 | -------------------------------------------------------------------------------- /stc/register.js: -------------------------------------------------------------------------------- 1 | function register() { 2 | $("error").innerHTML = ""; 3 | $("validate").style.display = "none"; 4 | 5 | try { 6 | var fields = ["username", "email", "password1", "password2"]; 7 | var error = ""; 8 | 9 | fields.each(function (field) { 10 | $(field).style.backgroundColor = "#fff"; 11 | }); 12 | 13 | fields.each(function (field) { 14 | if ($(field).value == "") { 15 | $(field).style.backgroundColor = "#fbb"; 16 | error += "Field " + field + " must be non-empty
"; 17 | } 18 | }); 19 | 20 | if ($("password1").value != $("password2").value) { 21 | error += "The passwords don't match" 22 | } 23 | 24 | if (error != "") { 25 | throw error; 26 | } 27 | 28 | $("userinfo").request({ 29 | method:"post", 30 | onFailure: function() { 31 | $("error").innerHTML = "An unknown error occured"; 32 | }, 33 | onSuccess: function(transport) { 34 | state = transport.responseText.evalJSON(); 35 | if (state.error.length) { 36 | $("error").innerHTML = state.error.join("
"); 37 | } else { 38 | $("validate").style.display = "block"; 39 | $("usage").style.display = "none"; 40 | } 41 | } 42 | }); 43 | } catch (e) { 44 | handleException(e); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Game/Factions/Dwarves.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Dwarves; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $dwarves => { 7 | C => 15, W => 3, P1 => 5, P2 => 7, 8 | EARTH => 2, color => 'gray', 9 | display => "Dwarves", 10 | faction_board_id => 7, 11 | ship => { 12 | level => 0, max_level => 0, 13 | }, 14 | teleport => { 15 | level => 0, max_level => 1, 16 | type => 'tunnel', 17 | cost => [ { W => 2 }, { W => 1 } ], 18 | gain => [ { VP => 4 }, { VP => 4 } ], 19 | }, 20 | tunnel_range => 1, 21 | tunnel_max_range => 1, 22 | dig => { 23 | level => 0, max_level => 2, 24 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 25 | advance_cost => { W => 2, C => 5, P => 1 }, 26 | advance_gain => [ { VP => 6 }, { VP => 6 } ], 27 | }, 28 | buildings => { 29 | D => { advance_cost => { W => 1, C => 2 }, 30 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 31 | TP => { advance_cost => { W => 2, C => 3 }, 32 | income => { C => [ 0, 3, 5, 7, 10 ], 33 | PW => [ 0, 1, 2, 4, 6 ] } }, 34 | TE => { advance_cost => { W => 2, C => 5 }, 35 | income => { P => [ 0, 1, 2, 3 ] } }, 36 | SH => { advance_cost => { W => 4, C => 6 }, 37 | advance_gain => [ { GAIN_TELEPORT => 1 } ], 38 | income => { PW => [ 0, 2 ] } }, 39 | SA => { advance_cost => { W => 4, C => 6 }, 40 | income => { P => [ 0, 1 ] } }, 41 | } 42 | }; 43 | 44 | -------------------------------------------------------------------------------- /config/apache.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerName terra.snellman.net 3 | DocumentRoot /home/jsnell/sites/terra/git/www-prod/ 4 | 5 | AddDefaultCharset utf-8 6 | 7 | RewriteEngine on 8 | 9 | RewriteRule ^(/stc/.*)$ $1 [L] 10 | RewriteRule ^(/data/.*)$ $1 [L] 11 | RewriteRule ^/validate/(.*)$ /app/register/validate/$1 [P] 12 | RewriteRule ^/validate-alias/(.*)$ /app/alias/validate/$1 [P] 13 | RewriteRule ^/validate-reset/(.*)$ /app/reset/validate/$1 [P] 14 | RewriteRule ^/((app)/(.*))?$ /$1 [L,PT] 15 | 16 | # RewriteRule ^/down.html$ /down.html [L] 17 | # RewriteRule ^(|/|.*) /down.html [R=307,L] 18 | # RewriteRule ^(|/|/index.html)$ /down.html [P] 19 | 20 | RewriteRule ^/(([a-z]+)/(.*))?$ /app/template/$1 [P] 21 | RewriteRule ^(|/|/index.html)$ /app/template/index [P] 22 | 23 | Options -Indexes +SymLinksIfOwnerMatch 24 | 25 | AddType application/json .json 26 | 27 | Header add "Cache-Control" "public, max-age=864000" 28 | 29 | 30 | FastCgiServer /home/jsnell/sites/terra/git/www-prod/lib/app.fcgi -initial-env ENV=prod -initial-env TM_CONFIG= -processes 5 31 | ScriptAlias /app/ "/home/jsnell/sites/terra/git/www-prod/lib/app.fcgi/" 32 | 33 | ScriptLog ${APACHE_LOG_DIR}/terra-debug.log 34 | ErrorLog ${APACHE_LOG_DIR}/terra-error.log 35 | LogLevel warn 36 | CustomLog ${APACHE_LOG_DIR}/terra-access.log combined 37 | 38 | CustomLog ${APACHE_LOG_DIR}/terra-timing.log "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\" %Dusec" 39 | 40 | 41 | -------------------------------------------------------------------------------- /pages/content/alias.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/alias.js" ], 5 | title => 'Register Email Alias', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |
11 |
12 | 13 |
Email Address 14 |
15 |
16 | 17 |
18 |
19 |

20 | You can register multiple email addresses under the same user 21 | account. 22 |

23 |

24 | Note that if you want to switch your ongoing games to use the new 25 | address, you'll need to ask the game admin to do that change on 26 | a game-by-game basis. 27 |

28 |
29 | 43 | -------------------------------------------------------------------------------- /src/Game/Factions/Fakirs.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Fakirs; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $fakirs => { 7 | C => 15, W => 3, P1 => 7, P2 => 5, 8 | FIRE => 1, AIR => 1, color => 'yellow', 9 | display => "Fakirs", 10 | faction_board_id => 1, 11 | ship => { 12 | level => 0, max_level => 0, 13 | }, 14 | teleport => { 15 | level => 0, max_level => 1, 16 | type => 'carpet', 17 | cost => [ { P => 1 }, { P => 1 } ], 18 | gain => [ { VP => 4 }, { VP => 4 } ], 19 | advance_gain => [ { carpet_range => 1 } ], 20 | }, 21 | carpet_range => 1, 22 | carpet_max_range => 4, 23 | dig => { 24 | level => 0, max_level => 1, 25 | cost => [ { W => 3 }, { W => 2 } ], 26 | advance_cost => { W => 2, C => 5, P => 1 }, 27 | advance_gain => [ { VP => 6 } ], 28 | }, 29 | buildings => { 30 | D => { advance_cost => { W => 1, C => 2 }, 31 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 32 | TP => { advance_cost => { W => 2, C => 3 }, 33 | income => { C => [ 0, 2, 4, 6, 8 ], 34 | PW => [ 0, 1, 2, 4, 6 ] } }, 35 | TE => { advance_cost => { W => 2, C => 5 }, 36 | income => { P => [ 0, 1, 2, 3 ] } }, 37 | SH => { advance_cost => { W => 4, C => 10 }, 38 | advance_gain => [ { GAIN_TELEPORT => 1 } ], 39 | income => { P => [ 0, 1 ] } }, 40 | SA => { advance_cost => { W => 4, C => 6 }, 41 | income => { P => [ 0, 1 ] } }, 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/Game/Factions/Witches.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Witches; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $witches => { 7 | C => 15, W => 3, P1 => 5, P2 => 7, 8 | AIR => 2, color => 'green', 9 | special => { 10 | mode => 'gain', 11 | map(("TW$_", { VP => 5 }), 1..8), 12 | }, 13 | display => "Witches", 14 | faction_board_id => 14, 15 | ship => { 16 | level => 0, max_level => 3, 17 | advance_cost => { C => 4, P => 1 }, 18 | advance_gain => [ { VP => 2 }, 19 | { VP => 3 }, 20 | { VP => 4 } ], 21 | }, 22 | dig => { 23 | level => 0, max_level => 2, 24 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 25 | advance_cost => { W => 2, C => 5, P => 1 }, 26 | advance_gain => [ { VP => 6 }, 27 | { VP => 6 } ], 28 | }, 29 | buildings => { 30 | D => { advance_cost => { W => 1, C => 2 }, 31 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 32 | TP => { advance_cost => { W => 2, C => 3 }, 33 | income => { C => [ 0, 2, 4, 6, 8 ], 34 | PW => [ 0, 1, 2, 4, 6] } }, 35 | TE => { advance_cost => { W => 2, C => 5 }, 36 | income => { P => [ 0, 1, 2, 3 ] } }, 37 | SH => { advance_cost => { W => 4, C => 6 }, 38 | advance_gain => [ { ACTW => 1 } ], 39 | income => { PW => [ 0, 2 ] } }, 40 | SA => { advance_cost => { W => 4, C => 6 }, 41 | income => { P => [ 0, 1 ] } }, 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/Game/Factions/Mermaids.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Mermaids; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $mermaids => { 7 | C => 15, W => 3, P1 => 3, P2 => 9, 8 | WATER => 2, 9 | color => 'blue', 10 | display => "Mermaids", 11 | faction_board_id => 6, 12 | ship => { 13 | level => 1, max_level => 5, 14 | advance_cost => { C => 4, P => 1 }, 15 | advance_gain => [ { VP => 0 }, 16 | { VP => 2 }, 17 | { VP => 3 }, 18 | { VP => 4 }, 19 | { VP => 5 } ], 20 | }, 21 | dig => { 22 | level => 0, max_level => 2, 23 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 24 | advance_cost => { W => 2, C => 5, P => 1 }, 25 | advance_gain => [ { VP => 6 }, 26 | { VP => 6 } ], 27 | }, 28 | buildings => { 29 | D => { advance_cost => { W => 1, C => 2 }, 30 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 31 | TP => { advance_cost => { W => 2, C => 3 }, 32 | income => { C => [ 0, 2, 4, 6, 8 ], 33 | PW => [ 0, 1, 2, 4, 6 ] } }, 34 | TE => { advance_cost => { W => 2, C => 5 }, 35 | income => { P => [ 0, 1, 2, 3 ] } }, 36 | SH => { advance_cost => { W => 4, C => 6 }, 37 | advance_gain => [ { GAIN_SHIP => 1 } ], 38 | income => { PW => [ 0, 4 ] } }, 39 | SA => { advance_cost => { W => 4, C => 8 }, 40 | income => { P => [ 0, 1 ] } }, 41 | } 42 | }; 43 | 44 | -------------------------------------------------------------------------------- /src/Game/Events.pm: -------------------------------------------------------------------------------- 1 | package Game::Events; 2 | use Moose; 3 | use Method::Signatures::Simple; 4 | 5 | use resources; 6 | 7 | has 'game' => (is => 'rw', required => 1); 8 | 9 | has 'faction' => (is => 'rw', 10 | default => sub { {} }); 11 | 12 | has 'global' => (is => 'rw', 13 | default => sub { {} }); 14 | 15 | has 'location' => (is => 'rw', 16 | default => sub { {} }); 17 | 18 | method faction_event($faction, $event, $count) { 19 | if (!defined $count) { 20 | die "$event\n"; 21 | } 22 | 23 | for my $name ('all', $faction->{name}) { 24 | my $round = $self->game()->{round}; 25 | my $turn = $self->game()->{turn}; 26 | $self->faction()->{$name}{$event}{round}{$round} += $count; 27 | $self->faction()->{$name}{$event}{round}{all} += $count; 28 | $self->faction()->{$name}{$event}{turn}{$round}{$turn} += $count; 29 | } 30 | } 31 | 32 | method location_event($faction, $location) { 33 | for my $name ($faction->{name}) { 34 | my $round = $self->game()->{round}; 35 | push @{$self->location()->{$name}{round}{$round}}, $location; 36 | push @{$self->location()->{$name}{round}{all}}, $location; 37 | } 38 | } 39 | 40 | method global_event($event, $count) { 41 | for my $round ('all', $self->game()->{round}) { 42 | $self->global()->{$event}{round}{$round} += $count; 43 | } 44 | } 45 | 46 | method data() { 47 | return { 48 | faction => $self->faction(), 49 | location => $self->location(), 50 | global => $self->global() 51 | } 52 | } 53 | 54 | 1; 55 | -------------------------------------------------------------------------------- /src/Game/Factions/Swarmlings.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Swarmlings; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $swarmlings => { 7 | C => 20, W => 8, P1 => 3, P2 => 9, 8 | FIRE => 1, EARTH => 1, 9 | WATER => 1, AIR => 1, color => 'blue', 10 | display => "Swarmlings", 11 | faction_board_id => 5, 12 | ship => { 13 | level => 0, max_level => 3, 14 | advance_cost => { C => 4, P => 1 }, 15 | advance_gain => [ { VP => 2 }, 16 | { VP => 3 }, 17 | { VP => 4 } ], 18 | }, 19 | dig => { 20 | level => 0, max_level => 2, 21 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 22 | advance_cost => { W => 2, C => 5, P => 1 }, 23 | advance_gain => [ { VP => 6 }, 24 | { VP => 6 } ], 25 | }, 26 | special => { 27 | mode => 'gain', 28 | map(("TW$_", { W => 3 }), 1..8), 29 | }, 30 | buildings => { 31 | D => { advance_cost => { W => 2, C => 3 }, 32 | income => { W => [ 2, 3, 4, 5, 6, 7, 8, 9, 9 ] } }, 33 | TP => { advance_cost => { W => 3, C => 4 }, 34 | income => { PW => [ 0, 2, 4, 6, 8 ], 35 | C => [ 0, 2, 4, 6, 9 ] } }, 36 | TE => { advance_cost => { W => 3, C => 6 }, 37 | income => { P => [ 0, 1, 2, 3 ] } }, 38 | SH => { advance_cost => { W => 5, C => 8 }, 39 | advance_gain => [ { ACTS => 1 } ], 40 | income => { PW => [ 0, 4 ] } }, 41 | SA => { advance_cost => { W => 5, C => 8 }, 42 | income => { P => [ 0, 2 ] } }, 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/Game/Factions/Cultists.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Cultists; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $cultists => { 7 | C => 15, W => 3, P1 => 5, P2 => 7, 8 | EARTH => 1, FIRE => 1, color => 'brown', 9 | display => "Cultists", 10 | faction_board_id => 10, 11 | ship => { 12 | level => 0, max_level => 3, 13 | advance_cost => { C => 4, P => 1 }, 14 | advance_gain => [ { VP => 2 }, 15 | { VP => 3 }, 16 | { VP => 4 } ], 17 | }, 18 | leech_effect => { 19 | taken => { 20 | CULT => 1, 21 | }, 22 | not_taken => { 23 | PW => 1, 24 | }, 25 | }, 26 | dig => { 27 | level => 0, max_level => 2, 28 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 29 | advance_cost => { W => 2, C => 5, P => 1 }, 30 | advance_gain => [ { VP => 6 }, 31 | { VP => 6 } ], 32 | }, 33 | buildings => { 34 | D => { advance_cost => { W => 1, C => 2 }, 35 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 36 | TP => { advance_cost => { W => 2, C => 3 }, 37 | income => { C => [ 0, 2, 4, 6, 8 ], 38 | PW => [ 0, 1, 2, 4, 6] } }, 39 | TE => { advance_cost => { W => 2, C => 5 }, 40 | income => { P => [ 0, 1, 2, 3 ] } }, 41 | SH => { advance_cost => { W => 4, C => 8 }, 42 | advance_gain => [ { VP => 7 } ], 43 | income => { PW => [ 0, 2 ] } }, 44 | SA => { advance_cost => { W => 4, C => 8 }, 45 | income => { P => [ 0, 1 ] } }, 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/Game/Factions/Halflings.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Halflings; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $halflings => { 7 | C => 15, W => 3, P1 => 3, P2 => 9, 8 | EARTH => 1, AIR => 1, color => 'brown', 9 | display => "Halflings", 10 | faction_board_id => 9, 11 | special => { 12 | mode => 'gain', 13 | SPADE => { VP => 1 } 14 | }, 15 | ship => { 16 | level => 0, max_level => 3, 17 | advance_cost => { C => 4, P => 1 }, 18 | advance_gain => [ { VP => 2 }, 19 | { VP => 3 }, 20 | { VP => 4 } ], 21 | }, 22 | dig => { 23 | level => 0, max_level => 2, 24 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 25 | advance_cost => { W => 2, C => 1, P => 1 }, 26 | advance_gain => [ { VP => 6 }, 27 | { VP => 6 } ], 28 | }, 29 | buildings => { 30 | D => { advance_cost => { W => 1, C => 2 }, 31 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 32 | TP => { advance_cost => { W => 2, C => 3 }, 33 | income => { C => [ 0, 2, 4, 6, 8 ], 34 | PW => [ 0, 1, 2, 4, 6] } }, 35 | TE => { advance_cost => { W => 2, C => 5 }, 36 | income => { P => [ 0, 1, 2, 3 ] } }, 37 | SH => { advance_cost => { W => 4, C => 8 }, 38 | advance_gain => [ { SPADE => 3 } ], 39 | subactions => { 40 | transform => 3, 41 | build => 1, 42 | }, 43 | income => { PW => [ 0, 2 ] } }, 44 | SA => { advance_cost => { W => 4, C => 6 }, 45 | income => { P => [ 0, 1 ] } }, 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/Server/UserInfo.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::UserInfo; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use Digest::SHA qw(sha1_hex); 12 | 13 | use DB::Connection; 14 | use DB::UserInfo; 15 | use DB::UserValidate; 16 | 17 | has 'mode' => (is => 'ro', required => 1); 18 | 19 | method handle($q, $query_username) { 20 | $self->no_cache(); 21 | 22 | my $dbh = get_db_connection; 23 | 24 | my $res = { error => [] }; 25 | 26 | my $username; 27 | 28 | eval { 29 | ($username) = check_username_is_registered $dbh, $query_username; 30 | }; 31 | 32 | if ($query_username eq 'top50') { 33 | $username = $query_username; 34 | } elsif ($@ or !defined $username) { 35 | return $self->output_json( 36 | { 37 | error => [ "No such user: $query_username" ] 38 | }); 39 | } elsif ($username ne $query_username) { 40 | return $self->output_json( 41 | { 42 | link => "/player/$username", 43 | error => [], 44 | }); 45 | } 46 | 47 | eval { 48 | if ($self->mode() eq 'stats') { 49 | $res->{stats} = fetch_user_stats $dbh, $username; 50 | } elsif ($self->mode() eq 'opponents') { 51 | $res->{opponents} = fetch_user_opponents $dbh, $username; 52 | } elsif ($self->mode() eq 'metadata') { 53 | $self->allow_cross_domain(); 54 | $res->{metadata} = fetch_user_metadata $dbh, $username; 55 | } else { 56 | die "unknown mode\n"; 57 | } 58 | }; if ($@) { 59 | $res = { error => [ $@ ] }; 60 | } 61 | 62 | $self->output_json($res); 63 | } 64 | 65 | 1; 66 | -------------------------------------------------------------------------------- /src/Game/Factions/Alchemists.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Alchemists; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $alchemists => { 7 | C => 15, W => 3, P1 => 5, P2 => 7, 8 | WATER => 1, FIRE => 1, color => 'black', 9 | display => "Alchemists", 10 | faction_board_id => 11, 11 | ship => { 12 | level => 0, max_level => 3, 13 | advance_cost => { C => 4, P => 1 }, 14 | advance_gain => [ { VP => 2 }, 15 | { VP => 3 }, 16 | { VP => 4 } ], 17 | }, 18 | dig => { 19 | level => 0, max_level => 2, 20 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 21 | advance_cost => { W => 2, C => 5, P => 1 }, 22 | advance_gain => [ { VP => 6 }, 23 | { VP => 6 } ], 24 | }, 25 | special => { 26 | SPADE => { PW => 2 }, 27 | enable_if => { SH => 1 }, 28 | mode => 'gain', 29 | }, 30 | exchange_rates => { 31 | C => { VP => 2 }, 32 | VP => { C => 1 } 33 | }, 34 | buildings => { 35 | D => { advance_cost => { W => 1, C => 2 }, 36 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 37 | TP => { advance_cost => { W => 2, C => 3 }, 38 | income => { C => [ 0, 2, 4, 7, 11 ], 39 | PW => [ 0, 1, 2, 3, 4 ] } }, 40 | TE => { advance_cost => { W => 2, C => 5 }, 41 | income => { P => [ 0, 1, 2, 3 ] } }, 42 | SH => { advance_cost => { W => 4, C => 6 }, 43 | advance_gain => [ { PW => 12 } ], 44 | income => { C => [ 0, 6 ] } }, 45 | SA => { advance_cost => { W => 4, C => 6 }, 46 | income => { P => [ 0, 1 ] } }, 47 | } 48 | }; 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/Game/Factions/Chaosmagicians.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Chaosmagicians; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $chaosmagicians => { 7 | C => 15, W => 4, P1 => 5, P2 => 7, 8 | FIRE => 2, 9 | color => 'red', 10 | display => "Chaos Magicians", 11 | faction_board_id => 3, 12 | ship => { 13 | level => 0, max_level => 3, 14 | advance_cost => { C => 4, P => 1 }, 15 | advance_gain => [ { VP => 2 }, 16 | { VP => 3 }, 17 | { VP => 4 } ], 18 | }, 19 | dig => { 20 | level => 0, max_level => 2, 21 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 22 | advance_cost => { W => 2, C => 5, P => 1 }, 23 | advance_gain => [ { VP => 6 }, 24 | { VP => 6 } ], 25 | }, 26 | buildings => { 27 | D => { advance_cost => { W => 1, C => 2 }, 28 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 8 ] } }, 29 | TP => { advance_cost => { W => 2, C => 3 }, 30 | income => { C => [ 0, 2, 4, 6, 8 ], 31 | PW => [ 0, 1, 2, 4, 6 ] } }, 32 | TE => { advance_cost => { W => 2, C => 5 }, 33 | advance_gain => [ { GAIN_FAVOR => 2 }, 34 | { GAIN_FAVOR => 2 }, 35 | { GAIN_FAVOR => 2 } ], 36 | income => { P => [ 0, 1, 2, 3 ] } }, 37 | SH => { advance_cost => { W => 4, C => 4 }, 38 | advance_gain => [ { ACTC => 1 } ], 39 | income => { W => [ 0, 2 ] } }, 40 | SA => { advance_cost => { W => 4, C => 8 }, 41 | advance_gain => [ { GAIN_FAVOR => 2 } ], 42 | income => { P => [ 0, 1 ] } }, 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /pages/content/player.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/index.js", 5 | "/stc/player.js" ], 6 | title => 'Player Profile', 7 | content => read_then_close(*DATA) 8 | } 9 | 10 | __DATA__ 11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 30 | 31 | 35 | 36 | 40 | 41 | 45 |
46 | 47 | 55 | -------------------------------------------------------------------------------- /pages/content/register.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/register.js",], 5 | title => 'Register New Account', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |
11 |
12 | 13 |
Username 14 |
Email Address 15 |
Password 16 |
Verify password 17 |
18 |
19 |
20 |
21 |

22 | The username should consist only of the letters A-Z and a-z, the 23 | digits 0-9, and the punctuation characters . 24 | _ and - . 25 |

26 |

27 | A valid email address is required, both for administration like 28 | password resets, and for facilitating email play. The address 29 | might be shown to any users playing in the same match, but not 30 | to any outsiders. 31 |

32 |
33 | 49 | -------------------------------------------------------------------------------- /pages/content/settings.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/settings.js"], 5 | title => 'Settings', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |
11 |
12 | 13 | 14 | 15 | 16 | 20 | 24 | 50 | 51 |
Username
PasswordChange password
Display Name
Email Addresses 17 | The following validated email addresses are associated with 18 | this account. 19 |
Primary email 21 | If you have multiple registered addresses, email notifications will 22 | be sent to this address. 23 |
Email Notifications 25 |
26 | For games with email notifications turned on, you'll get an 27 | email for the following events: 28 |
29 | 30 | 31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 | 41 | 42 |
43 |
44 | 45 | 46 |
47 | 48 |
49 |
52 |
53 | 56 | -------------------------------------------------------------------------------- /src/Analyze/EloVpPredictor.pm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -wl 2 | 3 | package Analyze::EloVpPredictor; 4 | use Exporter::Easy (EXPORT => ['faction_vp_error_by_map']); 5 | 6 | use strict; 7 | 8 | use DBI; 9 | use File::Slurp; 10 | use JSON; 11 | use List::Util qw(sum); 12 | use Statistics::Descriptive; 13 | 14 | use Analyze::RatingData; 15 | 16 | sub faction_vp_error_by_map { 17 | my ($dbh, $map) = @_; 18 | 19 | my $players = $dbh->selectall_hashref("select player, rating as score from player_ratings", 20 | 'player'); 21 | 22 | my %stats = (); 23 | 24 | my $results = read_rating_data $dbh, sub { 25 | my $res = shift; 26 | return $res->{base_map} && 27 | $res->{base_map} eq $map; 28 | }, {include_unranked => 1 }; 29 | 30 | my %diffs = (); 31 | my %counts = (); 32 | for my $record (@{$results->{results}}) { 33 | my $a_vp = $record->{a}{vp}; 34 | my $b_vp = $record->{b}{vp}; 35 | 36 | my $a_elo = $players->{$record->{a}{username}}{score}; 37 | my $b_elo = $players->{$record->{b}{username}}{score}; 38 | 39 | my $a_faction = $record->{a}{faction}; 40 | my $b_faction = $record->{b}{faction}; 41 | 42 | next if $record->{a}{dropped} or $record->{b}{dropped}; 43 | next if !$a_elo or !$b_elo; 44 | 45 | my $d_vp = $a_vp - $b_vp; 46 | my $d_elo = $a_elo - $b_elo; 47 | my $e_vp = $d_elo / 10; 48 | 49 | push @{$diffs{$a_faction}}, $d_vp - $e_vp; 50 | push @{$diffs{$b_faction}}, -($d_vp - $e_vp); 51 | 52 | for my $f ($a_faction, $b_faction) { 53 | $counts{$f}{$record->{id}}++; 54 | } 55 | } 56 | 57 | my %stat = map { 58 | my $stat = Statistics::Descriptive::Full->new(); 59 | $stat->add_data(@{$diffs{$_}}); 60 | my $count = scalar keys %{$counts{$_}}; 61 | ($_, { 62 | count => $count, 63 | mean => $stat->mean(), 64 | sterr => $stat->standard_deviation() / sqrt($count), 65 | }); 66 | } keys %diffs; 67 | 68 | \%stat; 69 | } 70 | 71 | 1; 72 | -------------------------------------------------------------------------------- /src/Analyze/BuildStats/builds.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -lw 2 | 3 | package terra_mystica; 4 | 5 | use strict; 6 | 7 | use DBI; 8 | use Digest::SHA qw(sha1_hex); 9 | use JSON; 10 | use POSIX; 11 | use File::Basename qw(dirname); 12 | use List::Util qw(sum); 13 | 14 | BEGIN { push @INC, "$ENV{PWD}/src/"; } 15 | 16 | use Analyze::RatingData; 17 | use DB::Connection; 18 | use DB::Game; 19 | use tracker; 20 | 21 | my $dbh = get_db_connection; 22 | 23 | my $results = read_rating_data $dbh, sub { 24 | my $res = shift; 25 | 26 | return 0 if $res->{player_count} != 4; 27 | 28 | # return 0 if sha1_hex($res->{game}) !~ /^f0/; 29 | $res->{base_map} //= '126fe960806d587c78546b30f1a90853b1ada468'; 30 | # return 0 if !defined $res->{base_map}; 31 | 32 | # return 0 if ($res->{base_map} // '') ne 'c07f36f9e050992d2daf6d44af2bc51dca719c46'; 33 | 34 | return 1; 35 | }, { include_unranked => 1}; 36 | 37 | for my $id (keys %{$results->{games}}) { 38 | my $game = $results->{games}{$id}; 39 | my @rows = get_game_commands $dbh, $id; 40 | my @command_stream = (); 41 | 42 | my $row = 0; 43 | for (@rows) { 44 | eval { push @command_stream, clean_commands $_ }; 45 | if ($@) { 46 | chomp; 47 | print STDERR "Error on line $row [$_]:"; 48 | print STDERR "$@\n"; 49 | last; 50 | } 51 | $row++; 52 | } 53 | 54 | my %record = (); 55 | $record{id} = $id; 56 | 57 | for my $row (@command_stream) { 58 | my $faction = $row->[0]; 59 | next if !$faction; 60 | next if $faction eq 'comment'; 61 | if ($faction eq 'riverwalkers' or $faction eq 'shapeshifters') { 62 | $faction .= "_v5"; 63 | } 64 | while ($row->[1] =~ /^build (\S+)/gi) { 65 | push @{$record{factions}{$faction}{builds}}, $1; 66 | } 67 | } 68 | 69 | for my $faction (keys %{$game->{factions}}) { 70 | for my $key (qw(vp rank)) { 71 | $record{factions}{$faction}{$key} = $game->{factions}{$faction}{$key}; 72 | } 73 | } 74 | 75 | print encode_json \%record; 76 | } 77 | -------------------------------------------------------------------------------- /pages/content/stats.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/stats.js"], 5 | title => 'Statistics', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |
11 | 12 |

Settings

13 | 14 |
15 | Player count
16 | 23 |
24 | 25 |
26 | Final scoring
27 | 32 |
33 | 34 |
35 | Map
36 | 45 |
46 | 47 |
48 | Rating
49 | 54 |
55 | 56 |

57 | Statistics computed from ? finished games. 58 | 59 |

Faction Statistics

60 | 61 |
62 |
63 | 64 |

High Scores

65 |
66 |
67 | 68 |

Start Position Statistics

69 |
70 |
71 | 72 |
73 | 74 | 75 | -------------------------------------------------------------------------------- /src/Util/PageGenerator.pm: -------------------------------------------------------------------------------- 1 | package Util::PageGenerator; 2 | use Exporter::Easy (EXPORT => [ 'generate_page' ]); 3 | 4 | use strict; 5 | 6 | use Digest::SHA qw(sha1_hex); 7 | use File::Slurp qw(read_file); 8 | use JSON; 9 | use Text::Template; 10 | 11 | my %page_data_cache = (); 12 | 13 | sub get_page_data { 14 | my ($dir, $name) = @_; 15 | my $content_file = "$dir/content/$name.pl"; 16 | die "No content file '$content_file'\n" if !-f $content_file; 17 | my $mtime = (stat($content_file))[9]; 18 | 19 | if (!defined $page_data_cache{$content_file}{mtime} or 20 | $mtime > $page_data_cache{$content_file}{mtime}) { 21 | $page_data_cache{$content_file}{mtime} = $mtime; 22 | $page_data_cache{$content_file}{content} = do $content_file; 23 | } 24 | 25 | $page_data_cache{$content_file}{content}; 26 | } 27 | 28 | sub generate_page { 29 | my ($root, $name, $params) = @_; 30 | my $dir = "$root/pages/"; 31 | 32 | $name =~ s/[^a-z]//g; 33 | my $data = get_page_data $dir, $name; 34 | 35 | if ($data->{title} and $data->{title} !~ /Terra Mystica/) { 36 | $data->{head_title} = "$data->{title} - Terra Mystica"; 37 | } else { 38 | $data->{head_title} = $data->{title}; 39 | } 40 | 41 | my $layout = "$dir/layout/$data->{layout}.html"; 42 | my $template = Text::Template->new(TYPE => 'FILE', 43 | SOURCE => $layout); 44 | die "Could not render page '$name', layout '$layout'\n" if !$template; 45 | 46 | if ($data->{require_access}) { 47 | if ($data->{require_access} ne $params->{access}) { 48 | print STDERR "Access denied to $name: ", encode_json $params, "\n"; 49 | die "Access restricted\n"; 50 | } 51 | } 52 | 53 | $data->{root} = $root; 54 | 55 | return $template->fill_in(HASH => $data); 56 | } 57 | 58 | sub static_resource_link { 59 | my ($root, $path) = @_; 60 | 61 | if ($path =~ /^http/) { 62 | return $path; 63 | } else { 64 | my $path_content = read_file "$root/$path"; 65 | my $csum = sha1_hex $path_content; 66 | return "$path?tag=$csum"; 67 | } 68 | } 69 | 70 | sub read_then_close { 71 | my ($fh) = @_; 72 | my $data = join '', <$fh>; 73 | close $fh; 74 | $data; 75 | } 76 | 77 | 1; 78 | -------------------------------------------------------------------------------- /src/Server/Results.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Results; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use DB::Connection; 12 | use DB::Game; 13 | use DB::Secret; 14 | 15 | method handle($q, $params) { 16 | my $dbh = get_db_connection; 17 | my ($secret, $iv) = get_secret $dbh; 18 | my %params = (); 19 | my $version = 'v1'; 20 | my @valid_versions = qw(v1 v2); 21 | 22 | if ($params =~ s{^v(\d+)/}{}) { 23 | $version = $1; 24 | my %valid_versions = map { ($_ => 1) } @valid_versions; 25 | 26 | if (!$valid_versions{"v$version"}) { 27 | die "Invalid version 'v$version'. (Valid values: @valid_versions)\n"; 28 | } 29 | } 30 | 31 | if ($params =~ m{^(\d+)/(\d+)(?:/(\d+))?$}) { 32 | %params = (year => $1, month => $2, day => $3); 33 | } else { 34 | die "Year/month not specified (e.g. /app/results/$version/2014/01). Day is optional (e.g. /app/results/$version/2014/01/01)\n" 35 | } 36 | 37 | my $results = { 38 | version => $version, 39 | get_finished_game_results $dbh, $secret, %params 40 | }; 41 | 42 | if ($version > 1) { 43 | $results->{games} = {}; 44 | $results->{players} = {}; 45 | 46 | for (@{$results->{results}}) { 47 | my $game = ($results->{games}{$_->{game}} //= { 48 | expansion_scoring => $_->{non_standard}, 49 | player_count => $_->{player_count}, 50 | base_map => ($_->{base_map} || '126fe960806d587c78546b30f1a90853b1ada468'), 51 | options => $_->{options}, 52 | last_update => $_->{last_update}, 53 | }); 54 | delete $_->{options}; 55 | delete $_->{non_standard}; 56 | delete $_->{player_count}; 57 | delete $_->{last_update}; 58 | delete $_->{base_map}; 59 | 60 | $results->{players}{$_->{id_hash}} //= { 61 | username => $_->{username} 62 | }; 63 | delete $_->{username}; 64 | delete $_->{game}; 65 | 66 | push @{$game->{players}}, $_; 67 | } 68 | 69 | delete $results->{results}; 70 | } 71 | 72 | $self->output_json($results); 73 | } 74 | 75 | 1; 76 | 77 | -------------------------------------------------------------------------------- /src/Server/Server.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Server; 4 | 5 | use JSON; 6 | use Moose; 7 | use Method::Signatures::Simple; 8 | 9 | has 'headers' => (is => '', 10 | traits => ['Array'], 11 | default => sub { [] }, 12 | handles => { 13 | headers => 'elements', 14 | push_header => 'push', 15 | }); 16 | has 'status' => (is => 'rw', 17 | default => 200); 18 | has 'output' => (is => 'rw', 19 | default => ''); 20 | has 'cookies' => (is => 'rw', 21 | default => sub { {} }); 22 | 23 | method set_header($header, $value) { 24 | $self->push_header($header); 25 | $self->push_header($value); 26 | } 27 | 28 | method output_psgi { 29 | [$self->status, 30 | [ $self->headers() ], 31 | [ $self->output() ]]; 32 | }; 33 | 34 | method redirect($where) { 35 | $self->status(303); 36 | $self->set_header("Location", $where); 37 | } 38 | 39 | method allow_cross_domain($where) { 40 | $self->set_header("Access-Control-Allow-Origin", "*"); 41 | } 42 | 43 | method no_cache() { 44 | $self->set_header("Cache-Control", "no-cache"); 45 | } 46 | 47 | method output_json($data) { 48 | $self->output_cookies(); 49 | $self->set_header("Content-type", "application/json"); 50 | $self->output(encode_json($data)); 51 | } 52 | 53 | method output_html($data) { 54 | $self->output_cookies(); 55 | $self->set_header("Content-type", "text/html"); 56 | $self->output($data); 57 | } 58 | 59 | method handle($q) { 60 | die "Server::Server::handle() not implemented"; 61 | } 62 | 63 | around handle => sub { 64 | my ($orig, $self, @args) = @_; 65 | eval { 66 | $self->$orig(@args); 67 | }; if ($@) { 68 | $self->status(500); 69 | $self->output_json({ error => [ "$@" ] }); 70 | } 71 | }; 72 | 73 | method output_cookies() { 74 | for my $key (keys %{$self->cookies()}) { 75 | my $data = $self->cookies()->{$key}; 76 | my $value = $data->[0]; 77 | my @attributes = @{$data->[1]}; 78 | $self->set_header("Set-Cookie", 79 | join '; ', "$key=$value", @attributes); 80 | } 81 | } 82 | 83 | method set_cookie($field, $value, $attributes) { 84 | $self->cookies()->{$field} = [$value, $attributes]; 85 | } 86 | 87 | 1; 88 | -------------------------------------------------------------------------------- /pages/content/forcedreset.pl: -------------------------------------------------------------------------------- 1 | { 2 | layout => 'sidebar', 3 | scripts => [ "/stc/common.js", 4 | "/stc/reset.js" ], 5 | title => 'Reset Password', 6 | content => read_then_close(*DATA) 7 | } 8 | 9 | __DATA__ 10 |

11 | Sorry, you have to change your password before you can log in 12 | again. 13 |

14 | 15 |

16 | Q: Why do I have to do this? 17 |

18 |

19 | Somebody has been automatically trying to log in to the site on a large number of accounts using very common passwords, such as 'password' or '123456'. In some cases they've then proceeded to enter (bad) moves for a player whose password they'd guessed.

20 |

21 | Your account appears to also use a very weak password (either derived from your username, from the name of this site, or present in lists of very common passwords). To prevent the same from being done to your account (or to stop it, if it's already happening) you will need to change to a better password before anything else can be done with this account. 22 |

23 | 24 |

Q: Why do I have to enter my email address here?

25 |

26 | All password changes are validated by sending an email to one of 27 | the email addresses you've registered with, to validate that it's 28 | really the account owner changing the password. 29 |

30 | 31 |
32 |
33 | 34 |
Email Address 35 |
New Password 36 |
New Password (again) 37 |
38 |
39 |
40 |
41 | 52 | -------------------------------------------------------------------------------- /src/DB/Settings.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package DB::Settings; 4 | use Exporter::Easy (EXPORT => [ 'fetch_user_settings', 5 | 'save_user_settings']); 6 | 7 | sub fetch_user_settings { 8 | my ($dbh, $username) = @_; 9 | 10 | my %res = (); 11 | 12 | my $player = $dbh->selectrow_hashref( 13 | "select username, displayname, email_notify_turn, email_notify_all_moves, email_notify_chat, email_notify_game_status from player where username = ?", 14 | {}, 15 | $username); 16 | $res{$_} = $player->{$_} for keys %{$player}; 17 | 18 | my $rows = $dbh->selectall_hashref( 19 | "select address, validated, is_primary from email where player = ?", 20 | 'address', 21 | { Slice => {} }, 22 | $username); 23 | $res{email} = $rows; 24 | 25 | \%res; 26 | } 27 | 28 | sub save_user_settings { 29 | my ($dbh, $username, $q) = @_; 30 | 31 | my $displayname = $q->param('displayname'); 32 | my $primary_email = $q->param('primary_email'); 33 | 34 | if (length $displayname > 30) { 35 | die "Display Name too long"; 36 | } 37 | 38 | $dbh->do("begin"); 39 | 40 | $dbh->do("update player set displayname=?, email_notify_turn=?, email_notify_all_moves=?, email_notify_chat=?, email_notify_game_status=? where username=?", 41 | {}, 42 | $displayname, 43 | scalar $q->param('email_notify_turn'), 44 | scalar $q->param('email_notify_all_moves'), 45 | scalar $q->param('email_notify_chat'), 46 | scalar $q->param('email_notify_game_status'), 47 | $username); 48 | 49 | if ($primary_email) { 50 | my ($exists) = $dbh->selectrow_array( 51 | "select count(*) from email where player = ? and address=lower(?)", 52 | { }, 53 | $username, 54 | $primary_email); 55 | 56 | if (!$exists) { 57 | die "'$primary_email' is not a registered email address for '$username'\n"; 58 | } 59 | 60 | $dbh->do("update email set is_primary=false where player=?", 61 | {}, 62 | $username); 63 | $dbh->do("update email set is_primary=true where player=? and address=lower(?)", 64 | {}, 65 | $username, 66 | $primary_email); 67 | $dbh->do("update game_role set email=? where faction_player=?", 68 | {}, 69 | $primary_email, 70 | $username); 71 | } 72 | 73 | $dbh->do("commit"); 74 | } 75 | 76 | 1; 77 | -------------------------------------------------------------------------------- /src/Server/Session.pm: -------------------------------------------------------------------------------- 1 | package Server::Session; 2 | use Exporter::Easy (EXPORT => [ 'session_token', 3 | 'username_from_session_token', 4 | 'ensure_csrf_cookie', 5 | 'verify_csrf_cookie_or_die']); 6 | 7 | use Digest::SHA qw(sha1_hex); 8 | use Crypt::Eksblowfish::Bcrypt qw(en_base64); 9 | 10 | use DB::Secret; 11 | use Util::CryptUtil; 12 | 13 | sub session_token { 14 | my ($dbh, $username, $seed) = @_; 15 | $seed =~ s{/}{_}g; 16 | $seed = substr($seed . "0"x8, 0, 8); 17 | 18 | my ($secret) = 19 | $dbh->selectrow_array("select password from player where username=?", 20 | {}, 21 | $username); 22 | if (!$secret) { 23 | die "Can't create session token for $username\n"; 24 | } 25 | my $head = "$seed/$username"; 26 | my $hash = sha1_hex "$head/$secret"; 27 | "$head/$hash" 28 | } 29 | 30 | sub username_from_session_token { 31 | my ($dbh, $token) = @_; 32 | return if !$token; 33 | 34 | my ($seed, $username, $hash) = (split m{/}, $token); 35 | my $expected_token = session_token $dbh, $username, $seed; 36 | 37 | if ($expected_token eq $token) { 38 | $username; 39 | } else { 40 | undef; 41 | } 42 | } 43 | 44 | sub ensure_csrf_cookie { 45 | my ($q, $server) = @_; 46 | 47 | die "Invalid call to ensure_csrf_cookie" if !$server; 48 | 49 | if (!$q->cookie("csrf-token")) { 50 | my $y = 86400*365; 51 | my $r = read_urandom_string_base64 8; 52 | $server->set_cookie("csrf-token", 53 | $r, ["Path=/", "Max-Age=$y"]); 54 | } 55 | } 56 | 57 | sub verify_csrf_cookie_or_die { 58 | my ($q, $server) = @_; 59 | my $cookie_token = $q->cookie("csrf-token"); 60 | my $param_token = $q->param("csrf-token"); 61 | 62 | die "Invalid call to verify_csrf_cookie_or_die" if !$server; 63 | 64 | if (!defined $cookie_token or 65 | !defined $param_token or 66 | $cookie_token ne $param_token) { 67 | $cookie_token //= 'undefined'; 68 | $param_token //= 'undefined'; 69 | # print STDERR "CSRF verification failure [$cookie_token] [$param_token]\n"; 70 | # print STDERR (" User: ", $q->cookie('session-username'), 71 | # "\n UA: ", $q->user_agent(), 72 | # "\n Path: $0\n"); 73 | $server->status(403); 74 | if ($cookie_token eq 'undefined') { 75 | ensure_csrf_cookie $q, $server; 76 | } 77 | die "CSRF token validation error"; 78 | } 79 | } 80 | 81 | 1; 82 | -------------------------------------------------------------------------------- /src/update-active-time.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -lw 2 | 3 | use strict; 4 | use JSON; 5 | use POSIX; 6 | use File::Basename qw(dirname); 7 | 8 | BEGIN { push @INC, "$ENV{PWD}/src/"; } 9 | 10 | use DB::Connection; 11 | use DB::Game; 12 | use tracker; 13 | 14 | my $dbh = get_db_connection; 15 | 16 | # Run every 10 minutes. 17 | my $interval = 10*60; 18 | 19 | sub handle { 20 | my ($row) = @_; 21 | 22 | my $delta = $interval; 23 | my @delta = (0, 0, 0, 0, 0, 0); 24 | 25 | if ($row->{seconds_since_update} < $interval) { 26 | return; 27 | } 28 | 29 | if ($row->{seconds_since_update} > 4*3600) { 30 | $delta[0] = $interval; 31 | } 32 | if ($row->{seconds_since_update} > 8*3600) { 33 | $delta[1] = $interval; 34 | } 35 | if ($row->{seconds_since_update} > 12*3600) { 36 | $delta[2] = $interval; 37 | } 38 | if ($row->{seconds_since_update} > 24*3600) { 39 | $delta[3] = $interval; 40 | } 41 | if ($row->{seconds_since_update} > 48*3600) { 42 | $delta[4] = $interval; 43 | } 44 | if ($row->{seconds_since_update} > 72*3600) { 45 | $delta[5] = $interval; 46 | } 47 | 48 | my $count = 49 | $dbh->do("update game_active_time set active_seconds=active_seconds + ?, active_seconds_4h=active_seconds_4h+?, active_seconds_8h=active_seconds_8h+?, active_seconds_12h=active_seconds_12h+?, active_seconds_24h=active_seconds_24h+?,active_seconds_48h=active_seconds_48h+?, active_seconds_72h=active_seconds_72h+? where game=? and player=?", 50 | {}, 51 | $delta, 52 | @delta, 53 | $row->{id}, 54 | $row->{faction_player}); 55 | 56 | if ($count == 0) { 57 | $dbh->do("insert into game_active_time (active_seconds, active_seconds_4h, active_seconds_8h, active_seconds_12h, active_seconds_24h, active_seconds_72h, active_seconds_48h, game, player) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", 58 | {}, 59 | $delta, 60 | @delta, 61 | $row->{id}, 62 | $row->{faction_player}); 63 | } 64 | } 65 | 66 | $dbh->do("begin"); 67 | 68 | my $games = $dbh->selectall_arrayref("select game.id, extract(epoch from now() - game.last_update) as seconds_since_update, game_role.faction_player from game left join game_role on game_role.game=game.id where not finished and (game_role.action_required or game_role.leech_required) and game.id like ? and game_role.faction_player is not null", 69 | { Slice => {} }, 70 | shift || '%'); 71 | 72 | for (@{$games}) { 73 | handle $_; 74 | } 75 | 76 | $dbh->do("commit"); 77 | -------------------------------------------------------------------------------- /src/Server/Login.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Login; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); 12 | 13 | use DB::Connection; 14 | use DB::UserValidate; 15 | use Server::Session; 16 | use Util::CryptUtil; 17 | use Util::PasswordQuality; 18 | use Util::ServerUtil; 19 | 20 | method handle($q) { 21 | my $form_username = $q->param('username'); 22 | my $password = $q->param('password'); 23 | 24 | my $dbh = get_db_connection; 25 | 26 | if ($form_username =~ /\@/) { 27 | eval { 28 | $form_username = check_email_is_registered $dbh, $form_username; 29 | } 30 | } 31 | 32 | my ($stored_password, $username) = $dbh->selectrow_array("select password, username from player where lower(username) = lower(?)", {}, $form_username); 33 | 34 | my $match = 0; 35 | my $invalid_user = 0; 36 | 37 | if (!$stored_password) { 38 | log_with_request $q, "login: invalid username for $form_username"; 39 | $invalid_user = 1; 40 | } elsif ($stored_password ne bcrypt($password, $stored_password)) { 41 | log_with_request $q, "login: invalid password for $form_username"; 42 | } else { 43 | # log_with_request $q, "login: ok for $form_username"; 44 | $match = 1; 45 | } 46 | 47 | $self->no_cache(); 48 | 49 | if ($match && password_too_weak $username, $password) { 50 | $self->set_header("Set-Cookie", "csrf-token=; Path=/"); 51 | $self->set_header("Set-Cookie", "session-username=; Path=/"); 52 | $self->set_header("Set-Cookie", "session-token=; Path=/; HttpOnly"); 53 | $self->redirect("/forcedreset/"); 54 | log_with_request $q, "login: forced password reset for $form_username" 55 | } elsif ($match) { 56 | my $token = session_token $dbh, $username, read_urandom_string_base64 8; 57 | my $y = 86400*365; 58 | ensure_csrf_cookie $q, $self; 59 | $self->set_header("Set-Cookie", 60 | "session-username=$username; Path=/; Max-Age=$y"); 61 | $self->set_header("Set-Cookie", 62 | "session-token=$token; Path=/; HttpOnly; Max-Age=$y"); 63 | $self->redirect("/"); 64 | } else { 65 | $self->set_header("Set-Cookie", "csrf-token=; Path=/"); 66 | $self->set_header("Set-Cookie", "session-username=; Path=/"); 67 | $self->set_header("Set-Cookie", "session-token=; Path=/; HttpOnly"); 68 | if ($invalid_user) { 69 | $self->redirect("/login/#invalid-user"); 70 | } else { 71 | $self->redirect("/login/#failed"); 72 | } 73 | } 74 | } 75 | 76 | 1; 77 | 78 | -------------------------------------------------------------------------------- /src/Server/SaveGame.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::SaveGame; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use Digest::SHA qw(sha1_hex); 12 | 13 | use DB::Chat; 14 | use DB::Connection; 15 | use DB::Game; 16 | use DB::SaveGame; 17 | use DB::UserValidate; 18 | use Text::Diff qw(diff); 19 | use Server::Session; 20 | use tracker; 21 | 22 | method handle($q) { 23 | $self->no_cache(); 24 | 25 | my $dbh = get_db_connection; 26 | 27 | my $username = username_from_session_token($dbh, 28 | $q->cookie('session-token') // ''); 29 | if (!defined $username) { 30 | my $out = { 31 | error => ["Not logged in"], 32 | location => "/login/", 33 | }; 34 | $self->output_json($out); 35 | return; 36 | } 37 | 38 | my $write_id = $q->param('game'); 39 | $write_id =~ s{.*/}{}; 40 | $write_id =~ s{[^A-Za-z0-9_]}{}g; 41 | my ($read_id) = $write_id =~ /(.*)_/g; 42 | 43 | my $orig_hash = $q->param('orig-hash'); 44 | my $new_content = $q->param('content'); 45 | 46 | begin_game_transaction $dbh, $read_id; 47 | 48 | my ($prefix_content, $orig_content) = 49 | get_game_content $dbh, $read_id, $write_id; 50 | 51 | my $res = {}; 52 | 53 | if (sha1_hex($orig_content) ne $orig_hash) { 54 | print STDERR "Concurrent modification [$orig_hash] [", sha1_hex($orig_content), "]"; 55 | $res->{error} = [ 56 | "Someone else made changes to the game. Please reload\n" 57 | ]; 58 | } else { 59 | $res = evaluate_and_save $dbh, $read_id, $write_id, $prefix_content, $new_content; 60 | } 61 | 62 | if (@{$res->{error}}) { 63 | $dbh->do("rollback"); 64 | } else { 65 | finish_game_transaction $dbh; 66 | 67 | my $a = "$orig_content\n"; 68 | my $b = "$new_content\n"; 69 | $a =~ s/\r//g; 70 | $b =~ s/\r//g; 71 | $a =~ s/\n+/\n/g; 72 | $b =~ s/\n+/\n/g; 73 | 74 | if ($a ne $b) { 75 | my $diff = diff \$a, \$b, { CONTEXT => 1 }; 76 | 77 | insert_chat_message($dbh, $read_id, 78 | 'admin', 79 | "Game was edited by $username:\n$diff", 80 | "round $res->{round}, turn $res->{turn}"); 81 | } 82 | } 83 | 84 | my $out = { 85 | error => $res->{error}, 86 | hash => sha1_hex($new_content), 87 | action_required => $res->{action_required}, 88 | factions => { 89 | map { 90 | ($_->{name}, { display => $_->{display}, color => $_->{color} }) 91 | } values %{$res->{factions}} 92 | }, 93 | players => get_game_players($dbh, $read_id) 94 | }; 95 | $self->output_json($out); 96 | } 97 | 98 | 1; 99 | -------------------------------------------------------------------------------- /stc/settings.js: -------------------------------------------------------------------------------- 1 | var state = null; 2 | 3 | function loadOrSaveSettings(save) { 4 | var target = "/app/settings/"; 5 | 6 | var form_params = { 7 | "cache-token": new Date() - Math.random(), 8 | "csrf-token": getCSRFToken() 9 | }; 10 | if (save) { 11 | form_params['displayname'] = $("displayname").value; 12 | form_params['email_notify_turn'] = $("email_notify_turn").checked; 13 | form_params['email_notify_all_moves'] = $("email_notify_all_moves").checked; 14 | form_params['email_notify_chat'] = $("email_notify_chat").checked; 15 | form_params['email_notify_game_status'] = $("email_notify_game_status").checked; 16 | try { 17 | form_params['primary_email'] = $("primary_email").value; 18 | } catch (e) { 19 | } 20 | form_params['save'] = 1; 21 | } 22 | 23 | disableDescendants($("settings")); 24 | 25 | new Ajax.Request(target, { 26 | method: "post", 27 | parameters: form_params, 28 | onSuccess: function(transport){ 29 | state = transport.responseText.evalJSON(); 30 | enableDescendants($("settings")); 31 | if (state.link) { 32 | document.location = state.link; 33 | } else if (state.error.length) { 34 | $("error").innerHTML = state.error.join("
"); 35 | } else { 36 | renderSettings(state); 37 | } 38 | } 39 | }); 40 | } 41 | 42 | function loadSettings() { 43 | loadOrSaveSettings(false); 44 | } 45 | 46 | function saveSettings() { 47 | loadOrSaveSettings(true); 48 | } 49 | 50 | function renderSettings(state) { 51 | $("username").innerHTML = state.username; 52 | $("displayname").value = state.displayname; 53 | var newEmailList = new Element("ul"); 54 | var first = true; 55 | var primarySelect = new Element("select", {"id": "primary_email"}); 56 | $H(state.email).each(function (elem) { 57 | var row = new Element("li"); 58 | var option = new Element("option", {"value": elem.key}).update(elem.key); 59 | 60 | if (first || elem.value.is_primary) { 61 | option.selected = true; 62 | } 63 | 64 | row.update(elem.key); 65 | newEmailList.insert(row); 66 | primarySelect.insert(option); 67 | first = false; 68 | }); 69 | newEmailList.insert(new Element("div").update( 70 | new Element("a", { "href": "/alias/request/"}).update( 71 | "Add new address"))); 72 | 73 | $("email_notify_turn").checked = state.email_notify_turn; 74 | $("email_notify_all_moves").checked = state.email_notify_all_moves; 75 | $("email_notify_chat").checked = state.email_notify_chat; 76 | $("email_notify_game_status").checked = state.email_notify_game_status; 77 | 78 | $("email").update(newEmailList); 79 | $("primary-email-container").update(primarySelect); 80 | } -------------------------------------------------------------------------------- /stc/buildstats.js: -------------------------------------------------------------------------------- 1 | var builds; 2 | 3 | function updateHeatmapOptions(hash) { 4 | var factions = {}; 5 | $H(builds).sortBy(function(elem) { 6 | return (mapNamesById[elem.key] || elem.key) 7 | } ).each(function(elem) { 8 | if (!mapNamesById[elem.key]) { 9 | return; 10 | } 11 | $("mapid").insert("") 13 | $H(elem.value.factions).each(function(elem) { 14 | factions[elem.key] = 1; 15 | }); 16 | }); 17 | $H(factions).sortBy(function(a, b) { return a.key }).each(function(elem) { 18 | var opt = new Element("option", { "value": elem.key }).updateText( 19 | factionPrettyName[elem.key]); 20 | setFactionStyleForElement(opt, elem.key); 21 | $("factionid").insert(opt); 22 | }); 23 | 24 | $("rankid").insert(""); 25 | $("rankid").insert(""); 26 | $("rankid").insert(""); 27 | $("rankid").insert(""); 28 | $("rankid").insert(""); 29 | 30 | if (hash) { 31 | var hash = hash.substr(1); 32 | var components = hash.split(','); 33 | if (components.length) { 34 | $("mapid").value = components.shift(); 35 | } 36 | if (components.length) { 37 | $("factionid").value = components.shift(); 38 | } 39 | if (components.length) { 40 | $("rankid").value = components.shift(); 41 | } 42 | } 43 | } 44 | 45 | function updateBuildHeatmap() { 46 | state = {}; 47 | var by_map = builds[$("mapid").value]; 48 | state.map = by_map.base_map; 49 | state.bridges = []; 50 | $H(state.map).each(function(elem) { 51 | var hex = elem.value; 52 | hex.label = ''; 53 | if (hex.color != 'white') { hex.forceColor='#fff'; } 54 | }); 55 | var by_faction = by_map.factions[$("factionid").value][$("rankid").value]; 56 | $H(by_faction.build).each(function(elem) { 57 | var id = elem.key; 58 | var hex = state.map[id]; 59 | var freq = elem.value / by_faction.games; 60 | hex.label = Math.floor(freq * 100) + "%"; 61 | hex.forceColor = "rgb(255, " + Math.floor((1 - freq) * 255) + ", 255)"; 62 | }); 63 | $("gamescount").updateText(by_faction.games); 64 | drawMap(); 65 | 66 | document.location.hash = $("mapid").value + "," + $("factionid").value + "," + $("rankid").value; 67 | setFactionStyleForElement($("factionid"), $("factionid").value); 68 | } 69 | 70 | function loadBuildHeatmap() { 71 | var target = "/data/buildstats.json"; 72 | new Ajax.Request(target, { 73 | method: "get", 74 | parameters: { 75 | "cache-token": new Date() - Math.random(), 76 | }, 77 | onSuccess: function(transport) { 78 | builds = transport.responseText.evalJSON(); 79 | updateHeatmapOptions(document.location.hash); 80 | updateBuildHeatmap(); 81 | $("map").show(); 82 | } 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /src/Game/Factions/Icemaidens.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Icemaidens; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | Readonly our $icemaidens => { 7 | C => 15, W => 3, P1 => 6, P2 => 6, 8 | GAIN_FAVOR => 1, 9 | PICK_COLOR => 1, 10 | WATER => 1, AIR => 1, 11 | color => 'ice', 12 | secondary_color => undef, 13 | display => "Ice Maidens", 14 | faction_board_id => undef, 15 | ship => { 16 | level => 0, max_level => 3, 17 | advance_cost => { C => 4, P => 1 }, 18 | advance_gain => [ { VP => 2 }, { VP => 3 }, { VP => 4 } ], 19 | }, 20 | dig => { 21 | level => 0, max_level => 2, 22 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 23 | advance_cost => { C => 5, W => 1, P => 1 }, 24 | advance_gain => [ { VP => 6 }, 25 | { VP => 6 } ], 26 | }, 27 | buildings => { 28 | D => { advance_cost => { W => 1, C => 2 }, 29 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ] } }, 30 | TP => { advance_cost => { W => 2, C => 3 }, 31 | income => { C => [ 0, 2, 4, 6, 8 ], 32 | PW => [ 0, 1, 2, 4, 6 ] } }, 33 | TE => { advance_cost => { W => 2, C => 5 }, 34 | income => { P => [ 0, 1, 2, 3 ] } }, 35 | SH => { advance_cost => { W => 4, C => 6 }, 36 | pass_vp => [ 37 | {}, 38 | { TE => [0, 3, 6, 9] } 39 | ], 40 | income => { PW => [ 0, 4 ] } }, 41 | SA => { advance_cost => { W => 4, C => 6 }, 42 | income => { P => [ 0, 1 ] } }, 43 | } 44 | }; 45 | 46 | Readonly our $icemaidens_playtest_v1 => { 47 | C => 15, W => 3, P1 => 6, P2 => 6, 48 | GAIN_FAVOR => 1, 49 | PICK_COLOR => 1, 50 | WATER => 1, AIR => 1, 51 | color => 'ice', 52 | secondary_color => undef, 53 | display => "Ice Maidens", 54 | faction_board_id => undef, 55 | ship => { 56 | level => 0, max_level => 3, 57 | advance_cost => { C => 4, P => 1 }, 58 | advance_gain => [ { VP => 2 }, { VP => 3 }, { VP => 4 } ], 59 | }, 60 | dig => { 61 | level => 0, max_level => 2, 62 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 63 | advance_cost => { C => 5, P => 1 }, 64 | advance_gain => [ { VP => 6 }, 65 | { VP => 6 } ], 66 | }, 67 | buildings => { 68 | D => { advance_cost => { W => 1, C => 2 }, 69 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ] } }, 70 | TP => { advance_cost => { W => 2, C => 3 }, 71 | income => { C => [ 0, 2, 4, 6, 8 ], 72 | PW => [ 0, 1, 2, 4, 6 ] } }, 73 | TE => { advance_cost => { W => 2, C => 5 }, 74 | income => { P => [ 0, 1, 2, 3 ] } }, 75 | SH => { advance_cost => { W => 4, C => 6 }, 76 | pass_vp => [ 77 | {}, 78 | { TE => [0, 3, 6, 9] } 79 | ], 80 | income => { PW => [ 0, 4 ] } }, 81 | SA => { advance_cost => { W => 4, C => 6 }, 82 | income => { P => [ 0, 1 ] } }, 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /src/Server/ListGames.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::ListGames; 4 | 5 | use Moose; 6 | use Method::Signatures::Simple; 7 | use Server::Server; 8 | 9 | extends 'Server::Server'; 10 | 11 | use CGI qw(:cgi); 12 | 13 | use DB::Connection; 14 | use DB::EditLink; 15 | use DB::Game; 16 | use DB::UserInfo; 17 | use Util::NaturalCmp; 18 | use Server::Session; 19 | 20 | has 'mode' => (is => 'ro', required => 1); 21 | 22 | method handle($q, $path_suffix) { 23 | $self->no_cache(); 24 | $self->set_header("Connection", "Close"); 25 | 26 | ensure_csrf_cookie $q, $self; 27 | 28 | my $dbh = get_db_connection; 29 | my $mode = $q->param('mode') // $self->mode() // 'all'; 30 | my $status = $q->param('status') // 'running'; 31 | 32 | my %res = (error => []); 33 | 34 | if ($mode eq 'user' or $mode eq 'admin' or $mode eq 'other-user') { 35 | my $user = username_from_session_token($dbh, 36 | $q->cookie('session-token') // ''); 37 | if ($mode eq 'other-user') { 38 | $user = $q->param("args"); 39 | } else { 40 | eval { 41 | verify_csrf_cookie_or_die $q, $self; 42 | }; if ($@) { 43 | $self->output_json({ error => ["csrf-error"] }); 44 | return; 45 | } 46 | } 47 | 48 | my %status = (finished => 1, running => 0); 49 | $self->user_games($dbh, 50 | \%res, 51 | $user, 52 | $mode, 53 | $status{$status}, 54 | 1*!!($mode eq 'admin')); 55 | } elsif ($mode eq 'open') { 56 | my $user = username_from_session_token($dbh, 57 | $q->cookie('session-token') // ''); 58 | $self->open_games($dbh, \%res, $user); 59 | } elsif ($mode eq 'by-pattern') { 60 | $self->allow_cross_domain(); 61 | my $pattern = $path_suffix; 62 | $pattern =~ s/[*]/%/g; 63 | $res{games} = get_game_list_by_pattern $dbh, $pattern; 64 | $res{error} = []; 65 | } 66 | 67 | $self->output_json({%res}); 68 | } 69 | 70 | method open_games($dbh, $res, $user) { 71 | if (!defined $user) { 72 | $res->{error} = ["Not logged in (login)"]; 73 | } else { 74 | my $user_info = fetch_user_metadata $dbh, $user; 75 | my $user_rating = $user_info->{rating} // 0; 76 | my $games = get_open_game_list $dbh; 77 | for my $game (@{$games}) { 78 | if (grep { $_ eq $user } @{$game->{players}}) { 79 | next; 80 | } 81 | if (($game->{minimum_rating} and 82 | $game->{minimum_rating} > $user_rating) or 83 | ($game->{maximum_rating} and 84 | $game->{maximum_rating} < $user_rating)) { 85 | $game = undef; 86 | } 87 | } 88 | $res->{games} = [ grep { $_ } @{$games} ]; 89 | } 90 | } 91 | 92 | method user_games($dbh, $res, $user, $mode, $status, $admin) { 93 | if (!defined $user) { 94 | $res->{error} = ["Not logged in (login)"] 95 | } else { 96 | $res->{games} = get_user_game_list $dbh, $user, $mode, $status, $admin; 97 | } 98 | } 99 | 100 | 1; 101 | -------------------------------------------------------------------------------- /src/Server/Plan.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Plan; 4 | 5 | use Moose; 6 | use Server::Server; 7 | 8 | extends 'Server::Server'; 9 | 10 | use Crypt::CBC; 11 | use DB::Connection; 12 | use DB::Secret; 13 | use Server::Security; 14 | use Server::Session; 15 | 16 | sub verify_key { 17 | my ($dbh, $id, $faction_key, $faction_name) = @_; 18 | my ($secret, $iv) = get_secret $dbh; 19 | 20 | my $cipher = Crypt::CBC->new(-key => $secret, 21 | -blocksize => 8, 22 | -iv => $iv, 23 | -add_header => 0, 24 | -cipher => 'Blowfish'); 25 | my $data = $cipher->decrypt(pack "h*", $faction_key); 26 | my $game_secret = unpack("h*", $data ^ $faction_name); 27 | 28 | my $write_id = "${id}_$game_secret"; 29 | my $valid = $dbh->selectrow_array("select count(*) from game where write_id=?", {}, $write_id); 30 | 31 | die "Invalid faction key\n" if !$valid; 32 | }; 33 | 34 | sub handle { 35 | my ($self, $q) = @_; 36 | $self->no_cache(); 37 | 38 | my $dbh = get_db_connection; 39 | 40 | my $id = $q->param('game'); 41 | $id =~ s{.*/}{}; 42 | $id =~ s{[^A-Za-z0-9_]}{}g; 43 | 44 | my $faction_name = $q->param('preview-faction'); 45 | my $faction_key = $q->param('faction-key'); 46 | my $set_note = $q->param('set-note'); 47 | 48 | my $username = username_from_session_token($dbh, 49 | $q->cookie('session-token') // ''); 50 | my %res = (error => []); 51 | 52 | eval { 53 | if (!$username) { 54 | die "Not logged in\n"; 55 | } 56 | 57 | if ($faction_key eq '') { 58 | get_write_id_for_user $dbh, $username, $id, $faction_name; 59 | } else { 60 | verify_key $dbh, $id, $faction_key, $faction_name; 61 | 62 | my $faction_player = $dbh->selectrow_array( 63 | "select faction_player from game_role where game=? and faction=?", 64 | {}, 65 | $id, 66 | $faction_name); 67 | 68 | if ($username ne $faction_player) { 69 | die "Trying to read another player's notes?\n"; 70 | } 71 | } 72 | 73 | if (defined $set_note) { 74 | $res{note} = $set_note; 75 | 76 | $dbh->do('begin'); 77 | my $res = $dbh->do( 78 | "delete from game_note where faction = ? and game = ?", 79 | {}, 80 | $faction_name, 81 | $id); 82 | $res = $dbh->do( 83 | "insert into game_note (faction, game, note, author) values (?, ?, ?, ?)", 84 | {}, 85 | $faction_name, 86 | $id, 87 | $set_note, 88 | $username); 89 | $dbh->do('commit'); 90 | } else { 91 | my $rows = $dbh->selectall_arrayref( 92 | "select note from game_note where faction = ? and game = ? and author = ?", 93 | {}, 94 | $faction_name, 95 | $id, 96 | $username); 97 | $res{note} = $rows->[0][0]; 98 | } 99 | }; if ($@) { 100 | $res{error} = [ "$@" ]; 101 | } 102 | 103 | $self->output_json(\%res); 104 | } 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/Game/Factions/Yetis.pm: -------------------------------------------------------------------------------- 1 | package Game::Factions::Yetis; 2 | 3 | use strict; 4 | use Readonly; 5 | 6 | my @power_action_names = map { "ACT$_" } 1..6; 7 | 8 | Readonly our $yetis => { 9 | C => 15, W => 3, P1 => 0, P2 => 12, 10 | PICK_COLOR => 1, 11 | EARTH => 1, AIR => 1, 12 | discount => { 13 | (map { ($_ => { PW => 1 }) } @power_action_names), 14 | }, 15 | color => 'ice', 16 | secondary_color => undef, 17 | display => "Yetis", 18 | faction_board_id => undef, 19 | building_strength => { 20 | SH => 4, 21 | SA => 4, 22 | }, 23 | ship => { 24 | level => 0, max_level => 3, 25 | advance_cost => { C => 4, P => 1 }, 26 | advance_gain => [ { VP => 2 }, 27 | { VP => 3 }, 28 | { VP => 4 } ], 29 | }, 30 | dig => { 31 | level => 0, max_level => 2, 32 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 33 | advance_cost => { W => 1, C => 5, P => 1 }, 34 | advance_gain => [ { VP => 6 }, 35 | { VP => 6 } ], 36 | }, 37 | buildings => { 38 | D => { advance_cost => { W => 1, C => 2 }, 39 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ] } }, 40 | TP => { advance_cost => { W => 2, C => 3 }, 41 | income => { C => [ 0, 2, 4, 6, 8 ], 42 | PW => [ 0, 2, 4, 6, 8 ] } }, 43 | TE => { advance_cost => { W => 2, C => 5 }, 44 | income => { P => [ 0, 1, 2, 3 ] } }, 45 | SH => { advance_cost => { W => 4, C => 6 }, 46 | advance_gain => [ { 47 | allow_reuse => { 48 | (map { ($_ => 1) } @power_action_names), 49 | }} ], 50 | income => { PW => [ 0, 4 ] } }, 51 | SA => { advance_cost => { W => 4, C => 6 }, 52 | income => { P => [ 0, 1 ] } }, 53 | }}; 54 | 55 | Readonly our $yetis_playtest_v1 => { 56 | C => 15, W => 3, P1 => 0, P2 => 12, 57 | PICK_COLOR => 1, 58 | EARTH => 1, AIR => 1, 59 | discount => { 60 | (map { ($_ => { PW => 1 }) } @power_action_names), 61 | }, 62 | color => 'ice', 63 | secondary_color => undef, 64 | display => "Yetis", 65 | faction_board_id => undef, 66 | building_strength => { 67 | SH => 4, 68 | SA => 4, 69 | }, 70 | ship => { 71 | level => 0, max_level => 3, 72 | advance_cost => { C => 4, P => 1 }, 73 | advance_gain => [ { VP => 2 }, 74 | { VP => 3 }, 75 | { VP => 4 } ], 76 | }, 77 | dig => { 78 | level => 0, max_level => 2, 79 | cost => [ { W => 3 }, { W => 2 }, { W => 1 } ], 80 | advance_cost => { W => 1, C => 5, P => 1 }, 81 | advance_gain => [ { VP => 6 }, 82 | { VP => 6 } ], 83 | }, 84 | buildings => { 85 | D => { advance_cost => { W => 1, C => 2 }, 86 | income => { W => [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ] } }, 87 | TP => { advance_cost => { W => 2, C => 3 }, 88 | income => { C => [ 0, 2, 4, 6, 8 ], 89 | PW => [ 0, 2, 4, 6, 8 ] } }, 90 | TE => { advance_cost => { W => 2, C => 5 }, 91 | income => { P => [ 0, 1, 2, 3 ] } }, 92 | SH => { advance_cost => { W => 4, C => 6 }, 93 | advance_gain => [ { 94 | allow_reuse => { 95 | (map { ($_ => 1) } @power_action_names), 96 | }} ], 97 | income => { PW => [ 0, 4 ] } }, 98 | SA => { advance_cost => { W => 4, C => 6 }, 99 | income => { P => [ 0, 1 ] } }, 100 | }}; 101 | -------------------------------------------------------------------------------- /src/diffgamelist.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -wl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use File::Basename qw(dirname); 7 | use IPC::Open2; 8 | use JSON; 9 | use Text::Diff qw(diff); 10 | 11 | BEGIN { push @INC, dirname $0 }; 12 | 13 | use DB::Connection; 14 | 15 | my $dir = dirname $0; 16 | my $time = 'total'; 17 | my %time = (); 18 | 19 | my %procs = (); 20 | sub get_proc { 21 | my ($target) = @_; 22 | if (!$procs{$target}) { 23 | my $pid = open2(my $reader, my $writer, "perl $dir/listgames.pl $target"); 24 | $procs{$target} = { 25 | output => $reader, 26 | input => $writer, 27 | pid => $pid, 28 | }; 29 | } 30 | 31 | return $procs{$target}; 32 | } 33 | 34 | sub request_result { 35 | my ($target, $game) = @_; 36 | my $proc = get_proc $target; 37 | my $in = $proc->{input}; 38 | 39 | print $in "$game"; 40 | } 41 | 42 | sub fetch_result { 43 | my ($target, $game) = @_; 44 | my $proc = get_proc $target; 45 | my $out = $proc->{output}; 46 | my $res = <$out>; 47 | my $json = decode_json $res; 48 | my $cost = $json->{cost}; 49 | 50 | if ($time eq 'total') { 51 | $time{$target} += $cost; 52 | } elsif ($time eq 'single') { 53 | printf " %s: %5.3f\n", $target, $cost; 54 | } 55 | 56 | for (@{$json->{res}}) { 57 | if (defined $_->{seconds_since_update}) { 58 | $_->{seconds_since_update} = int $_->{seconds_since_update}; 59 | } 60 | } 61 | 62 | $json; 63 | } 64 | 65 | my ($dir1, $dir2) = (shift, shift); 66 | 67 | my $dbh = get_db_connection; 68 | 69 | my @modes = ("user 0 0", 70 | "other-user 0 0", 71 | "user 1 0", 72 | "user 0 1"); 73 | 74 | my $players = $dbh->selectall_arrayref("select username from player", 75 | {}); 76 | 77 | my @queries = (); 78 | for my $player (@{$players}) { 79 | $player = $player->[0]; 80 | for my $mode (@modes) { 81 | push @queries, "$player $mode"; 82 | } 83 | } 84 | 85 | my $count = 0; 86 | for (@queries) { 87 | my $id = $_; 88 | ++$count; 89 | 90 | { 91 | local $| = 1; 92 | printf "."; 93 | } 94 | 95 | if ($count % 75 == 0) { 96 | print "\n"; 97 | if ($time eq 'total') { 98 | for my $dir ($dir1, $dir2) { 99 | printf "%s: %5.2f\n", $dir, $time{$dir}; 100 | } 101 | } 102 | } 103 | 104 | request_result $dir1, $id; 105 | request_result $dir2, $id; 106 | 107 | my $a = fetch_result $dir1, $id; 108 | my $b = fetch_result $dir2, $id; 109 | 110 | my $header_printed = 0; 111 | 112 | for my $key (keys %{$a}) { 113 | my $aa = $a->{$key}; 114 | my $bb = $b->{$key}; 115 | 116 | if (!ref $aa or !ref $bb) { 117 | next; 118 | } 119 | 120 | if ($key eq 'ledger') { 121 | $aa = convert_ledger $aa; 122 | $bb = convert_ledger $bb; 123 | my $aj = join "\n", map { to_json($_) } @{$aa}; 124 | my $bj = join "\n", map { to_json($_) } @{$bb}; 125 | if ($aj ne $bj) { 126 | print "\nDiff in $id" if !$header_printed++; 127 | # print "Ledger diffs"; 128 | print diff \$aj, \$bj; 129 | } 130 | } else { 131 | my $aj = to_json($aa, { pretty => 1, canonical => 1 }); 132 | my $bj = to_json($bb, { pretty => 1, canonical => 1 }); 133 | if ($aj ne $bj) { 134 | print "\nDiff in $id" if !$header_printed++; 135 | print diff \$aj, \$bj; 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Server/Alias.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Alias; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use Net::SMTP; 12 | 13 | use DB::Connection; 14 | use DB::Secret; 15 | use DB::Validation; 16 | use Server::Session; 17 | use Util::CryptUtil; 18 | use Util::SiteConfig; 19 | 20 | has 'mode' => (is => 'ro', required => 1); 21 | 22 | method handle($q, $suffix) { 23 | $self->no_cache(); 24 | my $dbh = get_db_connection; 25 | my $mode = $self->mode(); 26 | 27 | if ($mode eq 'validate') { 28 | $self->validate_alias($q, $dbh, $suffix); 29 | } elsif ($mode eq 'request') { 30 | $self->request_alias($q, $dbh); 31 | } else { 32 | die "Unknown mode $mode"; 33 | } 34 | } 35 | 36 | method request_alias($q, $dbh) { 37 | my @error = (); 38 | 39 | my $email = $q->param('email'); 40 | my $username = username_from_session_token($dbh, 41 | $q->cookie('session-token') // ''); 42 | 43 | if (!$username) { 44 | push @error, "not logged in"; 45 | } else { 46 | verify_csrf_cookie_or_die $q, $self; 47 | } 48 | 49 | if (!@error) { 50 | my ($email_in_use) = $dbh->selectrow_array("select count(*) from email where lower(address) = lower(?)", {}, $email); 51 | 52 | if ($email_in_use) { 53 | push @error, "The email address is already registered"; 54 | } 55 | } 56 | 57 | if (!@error) { 58 | my $data = { 59 | username => $username, 60 | email => $email, 61 | }; 62 | my $token = insert_to_validate $dbh, $data; 63 | 64 | my $url = sprintf "https://$config{domain}/app/alias/validate/%s", $token; 65 | 66 | my $smtp = Net::SMTP->new('localhost', ( Debug => 0 )); 67 | 68 | $smtp->mail("www-data\@$config{email_domain}"); 69 | if (!$smtp->to($email)) { 70 | push @error, "Invalid email address"; 71 | } else { 72 | $smtp->data(); 73 | $smtp->datasend("To: $email\n"); 74 | $smtp->datasend("From: noreply+alias\@$config{email_domain}\n"); 75 | $smtp->datasend("Subject: Email alias validation for Terra Mystica\n"); 76 | $smtp->datasend("\n"); 77 | $smtp->datasend("To validate this email as an alias, use the following link:\n"); 78 | $smtp->datasend(" $url\n"); 79 | $smtp->dataend(); 80 | } 81 | 82 | $smtp->quit; 83 | } 84 | 85 | $self->output_json({ error => [@error] }); 86 | } 87 | 88 | method validate_alias($q, $dbh, $suffix) { 89 | my $token = $suffix // $q->param('token'); 90 | 91 | my ($secret, $iv) = get_secret; 92 | eval { 93 | my @data = (); 94 | my $payload = fetch_validate_payload $dbh, $token; 95 | @data = ($payload->{username}, $payload->{email}); 96 | 97 | $self->add_alias($dbh, @data); 98 | $self->output_html("

Email alias registered

"); 99 | }; if ($@) { 100 | print STDERR "token: $token\n"; 101 | print STDERR $@; 102 | $self->output_html("

Validation failed

"); 103 | } 104 | } 105 | 106 | method add_alias($dbh, $user, $email) { 107 | my ($already_done) = $dbh->selectrow_array("select count(*) from email where lower(address) = lower(?) and player = ?", {}, $email, $user); 108 | 109 | if (!$already_done) { 110 | $dbh->do('begin'); 111 | $dbh->do('insert into email (address, player, validated, is_primary) values (lower(?), ?, ?, false)', 112 | {}, $email, $user, 1); 113 | $dbh->do('commit'); 114 | } 115 | 116 | return $already_done; 117 | } 118 | 119 | 1; 120 | -------------------------------------------------------------------------------- /stc/ratings.js: -------------------------------------------------------------------------------- 1 | var state = null; 2 | var id = document.location.pathname; 3 | 4 | function showRatings(kind, table, data) { 5 | var header = new Element("tr"); 6 | if (kind == "player") { 7 | header.insert(new Element("td").updateText("Rank")); 8 | } 9 | header.insert(new Element("td").updateText("Rating")); 10 | header.insert(new Element("td").updateText("Name")); 11 | header.insert(new Element("td").updateText("Games Played")); 12 | if (kind == "player") { 13 | header.insert(new Element("td").updateText("Breakdown")); 14 | } 15 | table.insert(header); 16 | 17 | var rank = 1; 18 | 19 | $H(data).sortBy(function (a) { return -a.value.score } ).each(function(elem) { 20 | var value = elem.value; 21 | var row = new Element("tr"); 22 | if (kind == "player") { 23 | if (rank == 1 || rank % 10 == 0) { 24 | row.insert(new Element("td").updateText(rank)); 25 | } else { 26 | row.insert(new Element("td")); 27 | } 28 | rank++; 29 | } 30 | row.insert(new Element("td").updateText(Math.floor(value.score))); 31 | if (kind == "player") { 32 | row.insert(new Element("td").update( 33 | new Element("a", {"href":"/player/" + value.username}). 34 | updateText(value.username))); 35 | row.insert(new Element("td").updateText(value.games)); 36 | var breakdown = new Element("table", { 37 | "class": "ranking-breakdown-table", 38 | }); 39 | breakdown.insert(new Element("tr").insert( 40 | new Element("td").updateText("Faction")).insert( 41 | new Element("td").updateText("Delta")).insert( 42 | new Element("td").updateText("Plays"))); 43 | 44 | $H(value.faction_breakdown).sortBy(function (elem) { return -elem.value.score }).each(function (elem) { 45 | var breakdown_row = new Element("tr"); 46 | breakdown_row.insert(factionTableCell(elem.key)); 47 | breakdown_row.insert(new Element("td").updateText(Math.round(elem.value.score))); 48 | breakdown_row.insert(new Element("td").updateText(Math.round(elem.value.count))); 49 | breakdown.insert(breakdown_row); 50 | }); 51 | breakdown.hide(); 52 | var cell = new Element("td"); 53 | var show = new Element("a", { href: "javascript:" }).updateText("show"); 54 | show.onclick = function() { show.hide(); breakdown.show(); }; 55 | cell.insert(show); 56 | cell.insert(breakdown); 57 | row.insert(cell); 58 | } else { 59 | row.insert(factionTableCell(value.name)); 60 | row.insert(new Element("td").updateText(value.games)); 61 | } 62 | table.insert(row); 63 | }); 64 | } 65 | 66 | function showLinks(id) { 67 | $(id + "-links").style.display = "block"; 68 | $(id + "-show-link").style.display = "none"; 69 | } 70 | 71 | function showFactionRatingsByMap(map) { 72 | showRatings("faction", 73 | $("faction-ratings-" + map), 74 | state["factions_by_map"][map]); 75 | } 76 | 77 | function loadRatings() { 78 | new Ajax.Request("/data/ratings.json", { 79 | method:"get", 80 | onSuccess: function(transport){ 81 | state = transport.responseText.evalJSON(); 82 | try { 83 | showRatings("player", $("player-ratings"), state["players"]); 84 | showFactionRatingsByMap("126fe960806d587c78546b30f1a90853b1ada468"); 85 | showFactionRatingsByMap("95a66999127893f5925a5f591d54f8bcb9a670e6"); 86 | showFactionRatingsByMap("be8f6ebf549404d015547152d5f2a1906ae8dd90"); 87 | showFactionRatingsByMap("fdb13a13cd48b7a3c3525f27e4628ff6905aa5b1"); 88 | $("timestamp").innerHTML = "Last updated: " + state.timestamp; 89 | } catch (e) { 90 | handleException(e); 91 | }; 92 | } 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /src/Server/JoinGame.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | no indirect; 3 | 4 | package Server::JoinGame; 5 | 6 | use Moose; 7 | use Server::Server; 8 | use Method::Signatures::Simple; 9 | 10 | extends 'Server::Server'; 11 | 12 | use DB::Connection; 13 | use DB::Game; 14 | use DB::IndexGame; 15 | use DB::SaveGame; 16 | use DB::UserInfo; 17 | use Email::Notify; 18 | use Server::Session; 19 | use tracker; 20 | 21 | sub joingame { 22 | my ($dbh, $read_id, $username) = @_; 23 | begin_game_transaction $dbh, $read_id; 24 | 25 | my ($wanted_player_count, $current_count, $already_playing, 26 | $minimum_rating, $maximum_rating) = 27 | $dbh->selectrow_array("select wanted_player_count, (select count(*) from game_player where game=game.id), (select count(*) from game_player where game=game.id and player=?), game_options.minimum_rating, game_options.maximum_rating from game left join game_options on game.id=game_options.game where id=?", 28 | {}, 29 | $username, 30 | $read_id); 31 | 32 | if (!defined $wanted_player_count) { 33 | die "Can't join a private game\n"; 34 | } 35 | if ($already_playing) { 36 | die "You've already joined this game\n"; 37 | } 38 | if ($wanted_player_count <= $current_count) { 39 | die "Game is already full\n"; 40 | } 41 | 42 | { 43 | my $user_metadata = fetch_user_metadata $dbh, $username; 44 | my $user_rating = ($user_metadata->{rating} // 0); 45 | if ($user_rating < ($minimum_rating // 0)) { 46 | die "Your rating ($user_rating) is too low to join\n"; 47 | } 48 | if ($user_rating > ($maximum_rating // 1e6)) { 49 | die "Your rating ($user_rating) is too high to join\n"; 50 | } 51 | } 52 | 53 | { 54 | my ($games_waiting) = $dbh->selectrow_array("select count(*) from game_role join game on game.id=game_role.game where faction_player=? and action_required=true and not (game.finished or game.aborted)", 55 | {}, 56 | $username); 57 | if ($games_waiting > 4) { 58 | die "It's your turn in a lot of games right now. Please make moves in those games before joining new ones.\n"; 59 | } 60 | } 61 | 62 | $dbh->do("insert into game_player (game, player, sort_key, index) values (?, ?, ?, ?)", 63 | {}, 64 | $read_id, 65 | $username, 66 | $current_count, 67 | $current_count); 68 | $dbh->do("update game set last_update = now() where id = ?", 69 | {}, 70 | $read_id); 71 | 72 | if ($wanted_player_count == $current_count + 1) { 73 | my $write_id = $dbh->selectrow_array("select write_id from game where id=?", 74 | {}, 75 | $read_id); 76 | my ($prefix_content, $orig_content) = 77 | get_game_content $dbh, $read_id, $write_id; 78 | 79 | my $res = evaluate_and_save $dbh, $read_id, $write_id, $prefix_content, $orig_content; 80 | notify_game_started $dbh, { 81 | name => $read_id, 82 | options => $res->{options}, 83 | players => $res->{players}, 84 | } 85 | } 86 | 87 | finish_game_transaction $dbh; 88 | } 89 | 90 | method handle($q) { 91 | my $dbh = get_db_connection; 92 | 93 | $self->no_cache(); 94 | verify_csrf_cookie_or_die $q, $self; 95 | 96 | my $username = username_from_session_token($dbh, 97 | $q->cookie('session-token') // ''); 98 | my $read_id = $q->param('game'); 99 | my $res = { 100 | error => [], 101 | }; 102 | 103 | if (!$username) { 104 | $res->error = [ "not logged in" ]; 105 | } else { 106 | eval { 107 | joingame $dbh, $read_id, $username; 108 | }; if ($@) { 109 | $res->{error} = [ $@ ]; 110 | print STDERR "Error in joingame: $@\n" 111 | } 112 | } 113 | 114 | $self->output_json($res); 115 | } 116 | 117 | 1; 118 | 119 | -------------------------------------------------------------------------------- /src/Server/PasswordReset.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::PasswordReset; 4 | 5 | use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); 6 | use Moose; 7 | use Method::Signatures::Simple; 8 | use Net::SMTP; 9 | 10 | use Bytes::Random::Secure qw(random_bytes); 11 | use DB::Connection; 12 | use DB::Secret; 13 | use DB::UserValidate; 14 | use DB::Validation; 15 | use Server::Session; 16 | use Server::Server; 17 | use Util::CryptUtil; 18 | use Util::PasswordQuality; 19 | use Util::SiteConfig; 20 | 21 | extends 'Server::Server'; 22 | 23 | has 'mode' => (is => 'ro', required => 1); 24 | 25 | method handle($q, $suffix) { 26 | $self->no_cache(); 27 | my $dbh = get_db_connection; 28 | my $mode = $self->mode(); 29 | 30 | if ($mode eq 'validate') { 31 | $self->validate_reset($q, $dbh, $suffix); 32 | } elsif ($mode eq 'request') { 33 | $self->request_reset($q, $dbh); 34 | } else { 35 | die "Unknown mode $mode"; 36 | } 37 | } 38 | 39 | method request_reset($q, $dbh) { 40 | my @error = (); 41 | 42 | my $email = $q->param('email'); 43 | my $password = $q->param('password'); 44 | my $password_again = $q->param('password_again'); 45 | my $username; 46 | 47 | if (!@error) { 48 | if ($password ne $password_again) { 49 | push @error, "The two passwords you've entered are different. Please try again." 50 | } 51 | } 52 | 53 | if (!@error) { 54 | $username = $dbh->selectrow_array("select player from email where address = lower(?)", {}, $email); 55 | 56 | if (!$username) { 57 | push @error, "The email address is not registered"; 58 | } 59 | } 60 | 61 | if (!@error) { 62 | my ($reason) = password_too_weak $username, $password; 63 | if ($reason) { 64 | push @error, "Bad password: $reason\n"; 65 | } 66 | } 67 | 68 | if (!@error) { 69 | my $secret = get_secret $dbh; 70 | 71 | my $salt = en_base64 (join '', map { chr int rand 256} 1..16); 72 | my $hashed_password = bcrypt($password, 73 | '$2a$08$'.$salt); 74 | my $data = { 75 | username => $username, 76 | email => $email, 77 | hashed_password => $hashed_password 78 | }; 79 | my $token = insert_to_validate $dbh, $data; 80 | 81 | my $url = sprintf "https://$config{domain}/app/reset/validate/%s", $token; 82 | 83 | my $smtp = Net::SMTP->new('localhost', ( Debug => 0 )); 84 | 85 | $smtp->mail("www-data\@$config{email_domain}"); 86 | if (!$smtp->to($email)) { 87 | push @error, "Invalid email address"; 88 | } else { 89 | $smtp->data(); 90 | $smtp->datasend("To: $email\n"); 91 | $smtp->datasend("From: noreply+registration\@$config{email_domain}\n"); 92 | $smtp->datasend("Subject: Password reset for Terra Mystica\n"); 93 | $smtp->datasend("\n"); 94 | $smtp->datasend("Username: $username\n"); 95 | $smtp->datasend("\n"); 96 | $smtp->datasend("To reset your password, use the following link:\n"); 97 | $smtp->datasend(" $url\n"); 98 | $smtp->dataend(); 99 | } 100 | 101 | $smtp->quit; 102 | } 103 | 104 | $self->output_json({ error => [@error] }); 105 | } 106 | 107 | method validate_reset($q, $dbh, $suffix) { 108 | my $token = $suffix // $q->param('token'); 109 | 110 | eval { 111 | my @data = (); 112 | my $payload = fetch_validate_payload $dbh, $token; 113 | @data = ($payload->{username}, $payload->{email}, 114 | $payload->{hashed_password}); 115 | 116 | $self->reset_password($dbh, @data); 117 | $self->output_html("

The password has been reset

"); 118 | }; if ($@) { 119 | print STDERR "token: $token\n"; 120 | print STDERR $@; 121 | $self->output_html("

Validation failed

"); 122 | } 123 | } 124 | 125 | method reset_password($dbh, $user, $email, $hashed_password) { 126 | $dbh->do('begin'); 127 | 128 | $dbh->do('update player set password=? where username=?', 129 | {}, 130 | $hashed_password, 131 | $user); 132 | 133 | $dbh->do('commit'); 134 | } 135 | 136 | 1; 137 | -------------------------------------------------------------------------------- /stc/index.js: -------------------------------------------------------------------------------- 1 | var state = null; 2 | var id = document.location.pathname; 3 | 4 | function listGames(games, div, mode, status) { 5 | var action_required_count = 0; 6 | var fields = $H({ 7 | "id": "Game", 8 | "link": "Faction", 9 | "time_since_update": "Last move", 10 | "round": "Round", 11 | "waiting_for": "Waiting for", 12 | "status_msg": "Status", 13 | "vp": "VP" 14 | }); 15 | if (isMobile.any()) { 16 | fields = $H({ 17 | "id": "Game", 18 | "link": "Faction", 19 | "status_msg": "Status", 20 | }); 21 | } 22 | 23 | var thead = new Element("thead"); 24 | var tbody = new Element("tbody"); 25 | fields.each(function (field) { 26 | thead.insert(new Element("td").update(field.value)); 27 | }); 28 | games.each(function(elem) { 29 | elem.status = ""; 30 | elem.status_msg = ""; 31 | if (elem.dropped) { 32 | elem.status = "game-status-dropped"; 33 | elem.status_msg = "dropped out"; 34 | } else if (elem.action_required && !elem.aborted) { 35 | elem.status = "game-status-action-required"; 36 | if (mode == "user") { 37 | elem.status_msg = "your turn"; 38 | } else { 39 | elem.status_msg = "player's turn"; 40 | } 41 | 42 | action_required_count++; 43 | 44 | if (elem.deadline_hours) { 45 | var deadline_seconds = elem.deadline_hours * 3600; 46 | if (elem.seconds_since_update >= deadline_seconds * 0.75) { 47 | var dropping_in_seconds = deadline_seconds - elem.seconds_since_update 48 | var dropping_in = seconds_to_pretty_time(dropping_in_seconds); 49 | 50 | if (dropping_in_seconds <= 0) { 51 | elem.status_msg += ", drop imminent "; 52 | } else { 53 | elem.status_msg += ", dropping in " + dropping_in; 54 | } 55 | elem.status = 'game-status-dropping-soon'; 56 | } 57 | } 58 | } else if (elem.unread_chat_messages > 0) { 59 | elem.status = "game-status-action-unread-chat"; 60 | elem.status_msg = "new chat"; 61 | action_required_count++; 62 | } 63 | 64 | if (elem.seconds_since_update) { 65 | elem.time_since_update = seconds_to_pretty_time(elem.seconds_since_update) + " ago"; 66 | } 67 | if (elem.vp) { elem.vp += " vp"; } 68 | if (elem.rank) { elem.vp += " (" + elem.rank + ")"; } 69 | 70 | if (elem.aborted) { 71 | elem.vp = ""; 72 | elem.status_msg = "aborted"; 73 | } else if (elem.finished) { 74 | elem.status_msg = "finished"; 75 | } 76 | elem.link = new Element("a", {"href": elem.link, "class": "passthrough-color"}).update(elem.role); 77 | 78 | var row = new Element("tr", {"class": elem.status}); 79 | fields.each(function(field) { 80 | var td; 81 | if (field.key == "link") { 82 | td = factionTableCell(elem.role); 83 | td.innerHTML = ""; 84 | td.insert(elem.link); 85 | } else { 86 | td = new Element("td").update(elem[field.key]) 87 | } 88 | row.insert(td); 89 | }); 90 | 91 | $(tbody).insert(row); 92 | }); 93 | if (mode == "user") { 94 | var link = new Element('a', {"href": "#", "accesskey": "n"}).update("Refresh"); 95 | link.onclick = function() { fetchGames(div, mode, status, nextGame); } 96 | var td = new Element('td').insert(link); 97 | var tr = new Element('tr').insert(new Element("td")).insert(td); 98 | $(tbody).insert(tr); 99 | 100 | if (status == "running") { 101 | moveRequired = (action_required_count > 0); 102 | setTitle(); 103 | } 104 | } 105 | 106 | $(div).update(""); 107 | $(div).insert(thead); 108 | $(div).insert(tbody); 109 | } 110 | 111 | function nextGame(games, div, mode, status) { 112 | games.each(function(elem) { 113 | if (elem.action_required) { document.location = elem.link; } 114 | }); 115 | listGames(games, div, mode, status); 116 | } 117 | 118 | -------------------------------------------------------------------------------- /src/DB/UserInfo.pm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | package DB::UserInfo; 4 | use Exporter::Easy ( 5 | EXPORT => [qw(fetch_user_metadata fetch_user_stats fetch_user_opponents)] 6 | ); 7 | 8 | use strict; 9 | 10 | sub fetch_user_metadata { 11 | my ($dbh, $username) = @_; 12 | 13 | my ($metadata) = 14 | $dbh->selectall_arrayref("select username, displayname, rating from player left join player_ratings on player.username=player_ratings.player where username=?", 15 | { Slice => {} }, 16 | $username); 17 | 18 | my ($games) = 19 | $dbh->selectall_arrayref("select game.id, game_role.dropped, game.finished, game.aborted from game_role left join game on game.id=game_role.game where game_role.faction_player=?", 20 | { Slice => {} }, 21 | $username); 22 | 23 | $metadata = $metadata->[0]; 24 | 25 | $metadata->{tournament} = 0; 26 | my %handled = (); 27 | for my $game (@{$games}) { 28 | next if $handled{$game->{id}}++; 29 | 30 | if ($game->{id} =~ /4pLeague_/) { 31 | $metadata->{tournament} = 1; 32 | } 33 | 34 | if ($game->{dropped}) { 35 | $metadata->{dropped}++; 36 | } elsif ($game->{aborted}) { 37 | $metadata->{aborted}++; 38 | } elsif ($game->{finished}) { 39 | $metadata->{finished}++; 40 | } else { 41 | $metadata->{running}++; 42 | } 43 | $metadata->{total_games}++; 44 | } 45 | 46 | $metadata; 47 | } 48 | 49 | sub fetch_user_stats { 50 | my ($dbh, $username) = @_; 51 | my $rows; 52 | 53 | if ($username eq 'top50') { 54 | $rows = $dbh->selectall_arrayref("select faction_full as faction, max(vp) as max_vp, sum(vp)/count(*) as mean_vp, count(*), count(case when rank = 1 then true end)*100/count(*) as win_percentage, array_agg(rank) as ranks from game_role where faction_player in (select player from player_ratings order by rating desc limit 50) and game in (select id from game where finished and not aborted and not exclude_from_stats) group by faction_full order by win_percentage desc", 55 | { Slice => {} }); 56 | } else { 57 | $rows = $dbh->selectall_arrayref("select faction_full as faction, max(vp) as max_vp, sum(vp)/count(*) as mean_vp, count(*), count(case when rank = 1 then true end) as wins, count(case when rank = 1 then true end)*100/count(*) as win_percentage, array_agg(rank) as ranks from game_role where faction_player=? and game in (select id from game where finished and not aborted and not exclude_from_stats) group by faction_full order by win_percentage desc", 58 | { Slice => {} }, 59 | $username); 60 | } 61 | 62 | $rows; 63 | } 64 | 65 | sub fetch_user_opponents { 66 | my ($dbh, $username) = @_; 67 | 68 | my %res = (); 69 | 70 | my ($games) = 71 | $dbh->selectall_arrayref("select game, array_agg(faction_player) as players, array_agg(rank) as ranks from game_role where game in (select game from game_role where faction_player=? and game in (select id from game where finished and not aborted)) group by game", 72 | { Slice => {} }, 73 | $username); 74 | 75 | for my $game (@{$games}) { 76 | my %ranks = (); 77 | 78 | while (@{$game->{ranks}}) { 79 | my $player = pop @{$game->{players}}; 80 | my $rank = pop @{$game->{ranks}}; 81 | 82 | next if !defined $player; 83 | 84 | $ranks{$player} = $rank; 85 | } 86 | for my $opponent (keys %ranks) { 87 | next if $opponent eq $username; 88 | 89 | $res{$opponent}{username} = $opponent; 90 | $res{$opponent}{count}++; 91 | if ($ranks{$username} > $ranks{$opponent}) { 92 | $res{$opponent}{opponent_better}++; 93 | } elsif ($ranks{$username} < $ranks{$opponent}) { 94 | $res{$opponent}{player_better}++; 95 | } else { 96 | $res{$opponent}{draw}++; 97 | } 98 | } 99 | } 100 | 101 | my @res = sort { $b->{count} <=> $a->{count} } values %res; 102 | 103 | [ @res ]; 104 | } 105 | 106 | 1; 107 | -------------------------------------------------------------------------------- /src/Server/Chat.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Chat; 4 | 5 | use Moose; 6 | use Server::Server; 7 | 8 | extends 'Server::Server'; 9 | 10 | use Crypt::CBC; 11 | use DB::Chat; 12 | use DB::Connection; 13 | use DB::Secret; 14 | use Email::Notify; 15 | use Server::Security; 16 | use Server::Session; 17 | use Util::SiteConfig; 18 | 19 | sub verify_key { 20 | my ($dbh, $id, $faction_key, $faction_name) = @_; 21 | my ($secret, $iv) = get_secret $dbh; 22 | 23 | my $cipher = Crypt::CBC->new(-key => $secret, 24 | -blocksize => 8, 25 | -iv => $iv, 26 | -add_header => 0, 27 | -cipher => 'Blowfish'); 28 | my $data = $cipher->decrypt(pack "h*", $faction_key); 29 | my $game_secret = unpack("h*", $data ^ $faction_name); 30 | 31 | my $write_id = "${id}_$game_secret"; 32 | my $valid = $dbh->selectrow_array("select count(*) from game where write_id=?", {}, $write_id); 33 | 34 | die "Invalid faction key\n" if !$valid; 35 | }; 36 | 37 | sub handle { 38 | my ($self, $q) = @_; 39 | $self->no_cache(); 40 | 41 | my $dbh = get_db_connection; 42 | 43 | my $id = $q->param('game'); 44 | $id =~ s{.*/}{}; 45 | $id =~ s{[^A-Za-z0-9_]}{}g; 46 | 47 | my $faction_name = $q->param('faction'); 48 | my $faction_key = $q->param('faction-key'); 49 | my $add_message = $q->param('add-message'); 50 | my $turn = $q->param('turn'); 51 | my $username = username_from_session_token($dbh, 52 | $q->cookie('session-token') // ''); 53 | 54 | my %res = ( error => [] ); 55 | my $prevalidated = 0; 56 | 57 | if ($faction_name eq '' and $username eq $config{site_admin_username}) { 58 | $faction_name = 'site-admin'; 59 | $prevalidated = 1; 60 | } 61 | 62 | eval { 63 | if (!$prevalidated) { 64 | if ($faction_key eq '') { 65 | get_write_id_for_user $dbh, $username, $id, $faction_name; 66 | } else { 67 | verify_key $dbh, $id, $faction_key, $faction_name; 68 | } 69 | } 70 | if (defined $add_message && $add_message =~ /\S/) { 71 | $dbh->do('begin'); 72 | insert_chat_message($dbh, $id, $faction_name, $add_message, $turn); 73 | $dbh->do('commit'); 74 | 75 | my $factions = $dbh->selectall_arrayref( 76 | "select game_role.faction as name, email.address as email, player.displayname from game_role left join email on email.player = game_role.faction_player left join player on player.username = game_role.faction_player where game = ? and email.is_primary", 77 | { Slice => {} }, 78 | $id); 79 | 80 | my $game_options = $dbh->selectrow_array( 81 | "select game_options from game where id=?", {}, $id); 82 | for my $option (@{$game_options}) { 83 | if ($option eq 'email-notify') { 84 | notify_new_chat $dbh, { 85 | name => $id, 86 | factions => { map { ($_->{name}, $_) } @{$factions} } 87 | }, $faction_name, $add_message; 88 | } 89 | } 90 | } 91 | 92 | my $rows = $dbh->selectall_arrayref( 93 | "select faction, message, extract(epoch from now() - posted_at) as message_age, posted_on_turn from chat_message where game = ? order by posted_at asc", 94 | { Slice => {} }, 95 | $id); 96 | 97 | if ($username) { 98 | $dbh->do("begin"); 99 | my $count = 100 | $dbh->do("update chat_read set last_read = now() where game=? and player=?", 101 | {}, 102 | $id, 103 | $username); 104 | if ($count == 0) { 105 | $dbh->do("insert into chat_read (last_read, game, player) values (now(), ?, ?)", 106 | {}, 107 | $id, 108 | $username); 109 | } 110 | $dbh->do("commit"); 111 | } 112 | 113 | $res{messages} = $rows; 114 | }; if ($@) { 115 | $res{error} = ["$@"]; 116 | } 117 | 118 | $self->output_json(\%res); 119 | } 120 | -------------------------------------------------------------------------------- /src/drop-idle-players.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | 3 | use strict; 4 | no indirect; 5 | 6 | use File::Basename; 7 | use JSON; 8 | 9 | BEGIN { push @INC, dirname $0 } 10 | 11 | use DB::Connection; 12 | use DB::Game; 13 | use DB::SaveGame; 14 | use Email::Notify; 15 | use JSON; 16 | use tracker; 17 | 18 | my $dbh = get_db_connection; 19 | 20 | my $id_pattern = '%'; 21 | 22 | # Old-style games with static move deadline 23 | my $idle_players = $dbh->selectall_arrayref("select game, array_agg(faction) as factions from game_role where game in (select id from game left join game_options on game_options.game=game.id where not finished and not aborted and game_options.chess_clock_hours_initial is null and last_update < now() - coalesce(game_options.deadline_hours, 24*7) * interval '1 hour') and (action_required or leech_required) and game like ? group by game", 24 | { Slice => {} }, 25 | $id_pattern); 26 | 27 | # New-style games with chess clock 28 | for my $x (qw(8 12 24)) { 29 | my $players = $dbh->selectall_arrayref("select game.id as game, array_agg(game_role.faction) as factions from game_role left join game on game.id=game_role.game left join game_active_time on game_active_time.game=game.id left join game_options on game.id=game_options.game and game_active_time.player=game_role.faction_player where not game.finished and not game_role.dropped and game.current_chess_clock_hours is not null and (game_active_time.active_seconds_${x}h >= game.current_chess_clock_hours * 3600) and game_options.chess_clock_grace_period=? and game.id like ? group by game.id", 30 | { Slice => {} }, 31 | $x, 32 | $id_pattern); 33 | push @{$idle_players}, @{$players}; 34 | } 35 | 36 | sub drop_factions_from_game { 37 | my ($read_id, $factions) = @_; 38 | my @factions = @{$factions}; 39 | 40 | if (-t STDOUT) { 41 | print "Dropping @factions from $read_id\n"; 42 | } 43 | 44 | my ($write_id) = $dbh->selectrow_array("select write_id from game where id=?", 45 | {}, 46 | $read_id); 47 | 48 | begin_game_transaction $dbh, $read_id; 49 | 50 | my $players = get_game_players($dbh, $read_id); 51 | my $metadata = get_game_metadata($dbh, $read_id); 52 | 53 | my ($prefix_content, $new_content) = get_game_content $dbh, $read_id, $write_id; 54 | my $append = ''; 55 | for my $faction (@factions) { 56 | $append .= "\ndrop-faction $faction" 57 | } 58 | $new_content .= $append; 59 | 60 | my $res = terra_mystica::evaluate_game { 61 | rows => [ split /\n/, "$prefix_content\n$new_content" ], 62 | faction_info => get_game_factions($dbh, $read_id), 63 | players => $players, 64 | metadata => $metadata, 65 | delete_email => 0 66 | }; 67 | 68 | if (@{$res->{error}}) { 69 | die join "\n", @{$res->{error}}; 70 | } 71 | 72 | save $dbh, $write_id, $new_content, $res; 73 | 74 | finish_game_transaction $dbh; 75 | 76 | if ($res->{options}{'email-notify'}) { 77 | my $factions = $dbh->selectall_arrayref( 78 | "select game_role.faction as name, email.address as email, player.displayname from game_role left join email on email.player = game_role.faction_player left join player on player.username = game_role.faction_player where game = ? and email.is_primary", 79 | { Slice => {} }, 80 | $read_id); 81 | for my $faction (@{$factions}) { 82 | my $eval_faction = $res->{factions}{$faction->{name}}; 83 | if ($eval_faction) { 84 | $faction->{recent_moves} = $eval_faction->{recent_moves}; 85 | $faction->{VP} = $eval_faction->{VP}; 86 | } 87 | } 88 | my $game = { 89 | name => $read_id, 90 | factions => { map { ($_->{name}, $_) } @{$factions} }, 91 | finished => $res->{finished}, 92 | options => $res->{options}, 93 | action_required => $res->{action_required}, 94 | }; 95 | notify_after_move $dbh, $write_id, $game, $factions[0], $append; 96 | } 97 | }; 98 | 99 | for (@{$idle_players}) { 100 | eval { 101 | drop_factions_from_game $_->{game}, $_->{factions}; 102 | }; if ($@) { 103 | print "Error in game $_->{game}: $@" 104 | } 105 | } 106 | 107 | -------------------------------------------------------------------------------- /src/income.pm: -------------------------------------------------------------------------------- 1 | package terra_mystica; 2 | 3 | use strict; 4 | 5 | use Game::Constants; 6 | 7 | use resources; 8 | use scoring; 9 | 10 | use vars qw(%game); 11 | 12 | sub faction_income { 13 | my $faction = shift; 14 | 15 | my %total_income = map { $_, 0 } qw(C W P PW); 16 | 17 | my %total_building_income = %total_income; 18 | my %total_favor_income = %total_income; 19 | my %total_bonus_income = %total_income; 20 | my %total_scoring_income = %total_income; 21 | 22 | return if $faction->{dummy}; 23 | 24 | my %buildings = %{$faction->{buildings}}; 25 | 26 | for my $building (values %buildings) { 27 | if (exists $building->{income}) { 28 | my %building_income = %{$building->{income}}; 29 | for my $type (keys %building_income) { 30 | my $delta = $building_income{$type}[$building->{level}]; 31 | if ($delta) { 32 | $total_building_income{$type} += $delta; 33 | } 34 | } 35 | } 36 | } 37 | 38 | for my $tile (keys %{$faction}) { 39 | if (!$faction->{$tile}) { 40 | next; 41 | } 42 | 43 | if ($tile =~ /^(BON|FAV)/) { 44 | my $tile_income = $tiles{$tile}{income}; 45 | for my $type (keys %{$tile_income}) { 46 | if ($tile =~ /^BON/ and $faction->{passed}) { 47 | $total_bonus_income{$type} += $tile_income->{$type}; 48 | } elsif ($tile =~ /^FAV/) { 49 | $total_favor_income{$type} += $tile_income->{$type}; 50 | } 51 | } 52 | } 53 | } 54 | 55 | my $scoring = current_score_tile; 56 | if ($scoring and $game{round} != 6) { 57 | my %scoring_income = %{$scoring->{income}}; 58 | 59 | my $mul = int($faction->{$scoring->{cult}} / $scoring->{req}); 60 | for my $type (keys %scoring_income) { 61 | $total_scoring_income{$type} += $scoring_income{$type} * $mul; 62 | } 63 | } 64 | 65 | # XXX: Nasty. Mutate the faction every time this function gets called, 66 | # rather than return the breakdown. 67 | $faction->{income_breakdown} = {}; 68 | 69 | $faction->{income_breakdown}{bonus} = \%total_bonus_income; 70 | $faction->{income_breakdown}{scoring} = \%total_scoring_income; 71 | $faction->{income_breakdown}{favors} = \%total_favor_income; 72 | $faction->{income_breakdown}{buildings} = \%total_building_income; 73 | 74 | for my $subincome (values %{$faction->{income_breakdown}}) { 75 | my $total = 0; 76 | for my $type (keys %{$subincome}) { 77 | $total_income{$type} += $subincome->{$type}; 78 | if (grep { $type eq $_} qw(C W P PW)) { 79 | $total += $subincome->{$type}; 80 | } 81 | } 82 | if (!$total) { 83 | $subincome = undef; 84 | } 85 | } 86 | 87 | return { 88 | total => \%total_income, 89 | ordered => [ \%total_scoring_income, 90 | \%total_bonus_income, 91 | \%total_building_income, 92 | \%total_favor_income ] 93 | }; 94 | } 95 | 96 | sub take_income_for_faction { 97 | my ($faction, $type) = @_; 98 | $type //= 15; 99 | die "Taking income twice for $faction->{name}\n" if 100 | $faction->{income_taken} & $type; 101 | 102 | if (!$game{planning}) { 103 | for my $f ($game{acting}->factions_in_turn_order()) { 104 | die "Can't take income for $faction->{name} ($f->{name} still active)\n" if 105 | !($f->{passed} or $f->{dropped} or $f->{dummy}); 106 | } 107 | } 108 | 109 | if ($game{round} == 0) { 110 | $faction->{passed} = 1; 111 | } 112 | 113 | my $income = faction_income $faction; 114 | my $mask = 1; 115 | for my $subincome (@{$income->{ordered}}) { 116 | if ($type & $mask) { 117 | gain $faction, $subincome; 118 | } 119 | $mask <<= 1; 120 | } 121 | 122 | $faction->{income_taken} |= $type; 123 | 124 | if ($faction->{SPADE}) { 125 | $game{acting}->require_action($faction, 126 | { type => 'transform', 127 | amount => $faction->{SPADE} }); 128 | } 129 | 130 | if ($faction->{CULT}) { 131 | $game{acting}->require_action($faction, 132 | { type => 'cult', 133 | amount => $faction->{CULT} }); 134 | } 135 | } 136 | 137 | 1; 138 | -------------------------------------------------------------------------------- /stc/player.js: -------------------------------------------------------------------------------- 1 | function fetchStats(table, type, callback, user) { 2 | var target = "/app/user/" + type + "/" + user; 3 | 4 | var form_params = { 5 | "cache-token": new Date() - Math.random(), 6 | "csrf-token": getCSRFToken(), 7 | }; 8 | 9 | new Ajax.Request(target, { 10 | method: "post", 11 | parameters: form_params, 12 | onSuccess: function(transport) { 13 | var response = transport.responseText.evalJSON(); 14 | if (response.error.length) { 15 | $("error").innerHTML = response.error.join("
"); 16 | } else if (response.link) { 17 | document.location = response.link; 18 | } else { 19 | callback(table, response); 20 | } 21 | } 22 | }); 23 | } 24 | 25 | function renderStats(table, stats) { 26 | $H(stats.stats).each(function (elem) { 27 | var data = elem.value; 28 | 29 | data.ranks = data.ranks.sort(); 30 | 31 | var row = new Element("tr"); 32 | row.insert(factionTableCell(data.faction)); 33 | ['wins', 'count', 'win_percentage', 'mean_vp', 'max_vp', 'ranks'].each(function (field) { 34 | row.insert(new Element("td").updateText(data[field])); 35 | }); 36 | table.insert(row); 37 | }); 38 | } 39 | 40 | function renderMetadata(table, stats) { 41 | var metadata = stats.metadata; 42 | var mapping = [ 43 | ["Username", metadata.username], 44 | ["Display Name", metadata.displayname], 45 | ["Rating", metadata.rating || '-'], 46 | ["Games Started", metadata.total_games || 0], 47 | [" Running", metadata.running || 0], 48 | [" Finished", metadata.finished || 0], 49 | [" Aborted", metadata.aborted || 0], 50 | [" Dropped Out", metadata.dropped || 0], 51 | ]; 52 | 53 | mapping.each(function(record) { 54 | var label = record[0]; 55 | var value = record[1]; 56 | table.insert(new Element("tr").insert( 57 | new Element("td").updateText(label)).insert( 58 | new Element("td").updateText(value))); 59 | }); 60 | 61 | if (metadata.tournament) { 62 | var row = new Element("tr"); 63 | table.insert(row); 64 | row.insert(new Element("td").updateText("Links")); 65 | row.insert(new Element("td").insert( 66 | new Element("a", {href:"http://tmtour.org/#/players/" + metadata.username}).updateText("Tournament profile"))); 67 | } 68 | 69 | } 70 | 71 | function renderOpponents(table, stats) { 72 | stats.opponents.each(function (elem) { 73 | var data = elem; 74 | 75 | var row = new Element("tr"); 76 | ['username', 'count', 'player_better', 'opponent_better', 'draw'].each(function (field) { 77 | var cell = new Element("td"); 78 | var value = data[field] || ""; 79 | 80 | if (field == 'username') { 81 | cell.insert(new Element("a", {"href": "/player/" + value}).updateText(value)); 82 | } else { 83 | cell.updateText(value); 84 | } 85 | if (field == 'opponent_better' && 86 | data.opponent_better > data.player_better) { 87 | cell.style.color = '#c00'; 88 | } 89 | row.insert(cell); 90 | }); 91 | table.insert(row); 92 | }); 93 | } 94 | 95 | var fetched = {}; 96 | 97 | function selectPlayerTab() { 98 | var hash = document.location.hash; 99 | if (!hash) { 100 | hash = "metadata" 101 | } else { 102 | hash = hash.sub(/#/, ''); 103 | } 104 | 105 | if (!fetched[hash]) { 106 | if (hash == "active") { 107 | fetchGames("games-active", "other-user", "running", listGames, user); 108 | } else if (hash == "finished") { 109 | fetchGames("games-finished", "other-user", "finished", listGames, user); 110 | } else if (hash == "stats") { 111 | fetchStats($("stats-table"), 'stats', renderStats, user); 112 | } else if (hash == "opponents") { 113 | fetchStats($("opponents-table"), 'opponents', renderOpponents, user); 114 | } else if (hash == "metadata") { 115 | fetchStats($("metadata-table"), 'metadata', renderMetadata, user); 116 | } 117 | fetched[hash] = true; 118 | } 119 | 120 | $$("#tabs div").each(function(tab) { tab.hide() }); 121 | $$("#tabs button").each(function(button) { button.style.fontWeight = "" }); 122 | 123 | $(hash + "-button").style.fontWeight = "bold"; 124 | $(hash).show(); 125 | } 126 | 127 | function switchToPlayerTab(tab) { 128 | document.location.hash = "#" + tab; 129 | selectPlayerTab(); 130 | } 131 | -------------------------------------------------------------------------------- /src/Server/Router.pm: -------------------------------------------------------------------------------- 1 | package Server::Router; 2 | 3 | use Server::Alias; 4 | use Server::AppendGame; 5 | use Server::Chat; 6 | use Server::EditGame; 7 | use Server::EditGame; 8 | use Server::JoinGame; 9 | use Server::ListGames; 10 | use Server::Login; 11 | use Server::Logout; 12 | use Server::Map; 13 | use Server::NewGame; 14 | use Server::PasswordReset; 15 | use Server::Plan; 16 | use Server::Register; 17 | use Server::Request; 18 | use Server::Results; 19 | use Server::SaveGame; 20 | use Server::Settings; 21 | use Server::Template; 22 | use Server::UserInfo; 23 | use Server::ViewGame; 24 | 25 | use CGI::PSGI; 26 | use JSON; 27 | use POSIX; 28 | use Util::ServerUtil; 29 | use Util::Watchdog; 30 | 31 | my %paths = ( 32 | # Operations on single games. To be renamed 33 | '/append-game/' => sub { 34 | Server::AppendGame->new() 35 | }, 36 | '/edit-game/' => sub { 37 | Server::EditGame->new({ mode => 'content' }) 38 | }, 39 | '/set-game-status/' => sub { 40 | Server::EditGame->new({ mode => 'status' }) 41 | }, 42 | '/view-game/' => sub { 43 | Server::ViewGame->new() 44 | }, 45 | '/join-game/' => sub { 46 | Server::JoinGame->new() 47 | }, 48 | '/new-game/' => sub { 49 | Server::NewGame->new() 50 | }, 51 | '/save-game/' => sub { 52 | Server::SaveGame->new() 53 | }, 54 | '/chat/' => sub { 55 | Server::Chat->new() 56 | }, 57 | '/plan/' => sub { 58 | Server::Plan->new() 59 | }, 60 | 61 | '/list-games/' => sub { 62 | Server::ListGames->new( mode => '') 63 | }, 64 | 65 | # Map editor 66 | '/map/preview/' => sub { 67 | Server::Map->new({ mode => 'preview' }) 68 | }, 69 | '/map/save/' => sub { 70 | Server::Map->new({ mode => 'save' }) 71 | }, 72 | '/map/view/' => sub { 73 | Server::Map->new({ mode => 'view' }) 74 | }, 75 | 76 | # Account management 77 | '/alias/request/' => sub { 78 | Server::Alias->new({ mode => 'request' }) 79 | }, 80 | '/alias/validate/' => sub { 81 | Server::Alias->new({ mode => 'validate' }) 82 | }, 83 | '/login/' => sub { 84 | Server::Login->new() 85 | }, 86 | '/logout/' => sub { 87 | Server::Logout->new() 88 | }, 89 | '/register/request/' => sub { 90 | Server::Register->new({ mode => 'request' }) 91 | }, 92 | '/register/validate/' => sub { 93 | Server::Register->new({ mode => 'validate' }) 94 | }, 95 | '/reset/request/' => sub { 96 | Server::PasswordReset->new({ mode => 'request' }) 97 | }, 98 | '/reset/validate/' => sub { 99 | Server::PasswordReset->new({ mode => 'validate' }) 100 | }, 101 | '/settings/' => sub { 102 | Server::Settings->new() 103 | }, 104 | 105 | # User information 106 | '/user/metadata/' => sub { 107 | Server::UserInfo->new({mode => 'metadata'}) 108 | }, 109 | '/user/opponents/' => sub { 110 | Server::UserInfo->new({mode => 'opponents'}) 111 | }, 112 | '/user/stats/' => sub { 113 | Server::UserInfo->new({mode => 'stats'}) 114 | }, 115 | 116 | # Content rendering 117 | '/template/' => sub { 118 | Server::Template->new() 119 | }, 120 | 121 | # External APIs 122 | '/results/' => sub { 123 | Server::Results->new() 124 | }, 125 | '/list-games/by-pattern/' => sub { 126 | Server::ListGames->new( mode => 'by-pattern') 127 | }, 128 | ); 129 | 130 | sub route { 131 | my $env = shift; 132 | my $q = Server::Request->new($env); 133 | 134 | my $path_info = $q->path_info(); 135 | my $ret; 136 | 137 | eval { 138 | my $handler = undef; 139 | my $suffix = ''; 140 | my @components = split m{/}, $path_info; 141 | for my $i (reverse 0..$#components) { 142 | my $prefix = join '/', @components[0..$i]; 143 | $prefix .= '/'; 144 | $handler = $paths{$prefix}; 145 | if ($handler) { 146 | $suffix = substr $path_info, length $prefix; 147 | last; 148 | } 149 | } 150 | if ($handler) { 151 | my $app = $handler->(); 152 | with_watchdog 15, sub { 153 | $app->handle($q, $suffix); 154 | $ret = $app->output_psgi(); 155 | }; 156 | } else { 157 | die "Unknown module '$path_info'"; 158 | } 159 | }; if ($@) { 160 | my $error = $@; 161 | log_with_request $q, "$error"; 162 | 163 | $ret = [500, 164 | ["Content-Type", "application/json"], 165 | [encode_json { error => [ "$error" ] }]]; 166 | } 167 | 168 | $ret; 169 | }; 170 | 171 | sub psgi_router { 172 | route(@_); 173 | } 174 | 175 | 1; 176 | -------------------------------------------------------------------------------- /src/Game/Factions.pm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -wl 2 | 3 | package Game::Factions; 4 | use Exporter::Easy (EXPORT => [ 'setup_faction', 'factions_conflict' ]); 5 | 6 | no indirect qw(fatal); 7 | use strict; 8 | 9 | use Method::Signatures::Simple; 10 | 11 | use Game::Constants; 12 | 13 | sub clone { 14 | my $data = shift; 15 | my $ref = ref $data; 16 | 17 | if (!$ref) { 18 | return $data; 19 | } elsif ($ref eq 'HASH') { 20 | return { map { ($_, clone($data->{$_})) } keys %{$data} } 21 | } elsif ($ref eq 'ARRAY') { 22 | return [ map { clone($_) } @{$data} ] 23 | } else { 24 | die "Don't know how to clone a $ref\n"; 25 | } 26 | }; 27 | 28 | func initialize_faction($game, $faction_name) { 29 | my $faction; 30 | 31 | for my $variant (@{$game->{faction_variants}}) { 32 | $faction //= clone $faction_setups_extra{$variant}{$faction_name}; 33 | } 34 | $faction //= clone $faction_setups{$faction_name}; 35 | 36 | die "Unknown faction: $faction_name\n" if !$faction; 37 | 38 | $faction->{name} = $faction_name; 39 | $faction->{allowed_actions} = 0; 40 | 41 | $faction->{P} ||= 0; 42 | $faction->{P1} ||= 0; 43 | $faction->{P2} ||= 0; 44 | $faction->{P3} ||= 0; 45 | 46 | my $initial_vp = 20; 47 | if (defined $game->{vp_setup}{$faction_name}) { 48 | $initial_vp = $game->{vp_setup}{$faction_name}; 49 | } 50 | $faction->{VP} = $faction->{vp_source}{initial} = $initial_vp; 51 | $faction->{KEY} = 0; 52 | 53 | $faction->{MAX_P} //= 7; 54 | 55 | for (@cults) { 56 | $faction->{$_} ||= 0; 57 | $faction->{"MAX_$_"} = 10; 58 | } 59 | $faction->{'CULT'} ||= 0; 60 | $faction->{'CULT_P'} ||= 0; 61 | 62 | my $buildings = $faction->{buildings}; 63 | $buildings->{D}{max_level} = 8; 64 | $buildings->{TP}{max_level} = 4; 65 | $buildings->{SH}{max_level} = 1; 66 | $buildings->{TE}{max_level} = 3; 67 | $buildings->{SA}{max_level} = 1; 68 | 69 | for (0..2) { 70 | $buildings->{TE}{advance_gain}[$_]{GAIN_FAVOR} ||= 1; 71 | } 72 | $buildings->{SA}{advance_gain}[0]{GAIN_FAVOR} ||= 1; 73 | 74 | for my $building (values %{$buildings}) { 75 | $building->{level} = 0; 76 | } 77 | 78 | $faction->{SPADE} = 0; 79 | $faction->{TOWN_SIZE} = 7; 80 | $faction->{BRIDGE_COUNT} = 3; 81 | $faction->{planning} = 0; 82 | 83 | my %base_exchange_rates = ( 84 | PW => { C => 1, W => 3, P => 5 }, 85 | W => { C => 1 }, 86 | P => { C => 1, W => 1 }, 87 | C => { VP => 3 } 88 | ); 89 | if ($faction->{exchange_rates}) { 90 | for my $from_key (keys %{$faction->{exchange_rates}}) { 91 | my $from = $faction->{exchange_rates}{$from_key}; 92 | for my $to_key (keys %{$from}) { 93 | $base_exchange_rates{$from_key}{$to_key} = $from->{$to_key}; 94 | } 95 | } 96 | } 97 | $faction->{exchange_rates} = { %base_exchange_rates }; 98 | 99 | return $faction; 100 | } 101 | 102 | func factions_conflict($faction, $other) { 103 | my $tags = sub { 104 | my $f = shift; 105 | map { ($_, 1) } grep { $_ } map { $f->{$_} } qw(color board secondary_color) 106 | }; 107 | 108 | my %faction_tags = $tags->($faction); 109 | my %other_tags = $tags->($other); 110 | 111 | for (keys %faction_tags) { 112 | return 1 if $other_tags{$_}; 113 | } 114 | 115 | return 0; 116 | } 117 | 118 | func setup_faction($game, $faction_name, $player, $email) { 119 | my $acting = $game->{acting}; 120 | 121 | my $faction = initialize_faction($game, $faction_name); 122 | my $player_record = {}; 123 | my $players = $acting->players(); 124 | if (@{$players}) { 125 | $player_record = $players->[$acting->faction_count()]; 126 | if ($player and $player ne $player_record->{name}) { 127 | die "Expected ".($player_record->{name})." to pick a faction"; 128 | } 129 | $email ||= $player_record->{email}; 130 | if (!$player) { 131 | $player = $player_record->{name}; 132 | } 133 | } 134 | 135 | if (defined $player) { 136 | $faction->{player} = "$player"; 137 | $faction->{username} = $player_record->{username}; 138 | } 139 | 140 | $faction->{email} = $email; 141 | 142 | for my $other_faction ($acting->factions_in_order(1)) { 143 | if (factions_conflict($faction, $other_faction)) { 144 | die "Can't add $faction_name, $other_faction->{name} already in use\n"; 145 | } 146 | } 147 | 148 | $faction->{start_player} = 1 if !$acting->faction_count(); 149 | $faction->{income_taken} = 0; 150 | $game->{acting}->register_faction($faction); 151 | $faction->{start_order} = $acting->faction_count(); 152 | } 153 | 154 | 1; 155 | -------------------------------------------------------------------------------- /src/towns.pm: -------------------------------------------------------------------------------- 1 | package terra_mystica; 2 | 3 | use strict; 4 | 5 | use map; 6 | use Util::NaturalCmp; 7 | 8 | # Add a hex with a building owned by faction to the town denoted by tid. 9 | # Also add all transitively directly adjacent buildings to the town. 10 | sub add_to_town { 11 | my ($faction, $where, $tid) = @_; 12 | 13 | $map{$where}{town} = $tid; 14 | 15 | for my $adjacent (adjacent_own_buildings $faction, $where) { 16 | if (!$map{$adjacent}{town}) { 17 | add_to_town($faction, $adjacent, $tid); 18 | } 19 | } 20 | } 21 | 22 | # Given a faction and a hex, check whether something that happened in 23 | # that hex now allows for a formation of a new town. 24 | sub detect_towns_from { 25 | my ($faction, $where) = @_; 26 | 27 | # Must not already be part of a town. 28 | return 0 if $map{$where}{town}; 29 | 30 | # Must not be empty. 31 | return 0 if !$map{$where}{building}; 32 | 33 | # Must be controlled by faction. (Necessary e.g. when a bridge is 34 | # added). 35 | return 0 if $map{$where}{color} ne $faction->{color}; 36 | 37 | my @adjacent = keys %{$map{$where}{adjacent}}; 38 | 39 | # We might need to merge the building to existing town instead of 40 | # forming a new one. 41 | for my $adjacent (adjacent_own_buildings $faction, $where) { 42 | if ($map{$adjacent}{town}) { 43 | add_to_town $faction, $where, $map{$adjacent}{town}; 44 | } 45 | } 46 | 47 | # ... and if that happened, we need to bail out at this point. 48 | return 0 if $map{$where}{town}; 49 | 50 | my %reachable = (); 51 | my $power = 0; 52 | my $count = 0; 53 | 54 | # Count the number and power of the buildings reachable from this 55 | # hex. 56 | my $handle; 57 | $handle = sub { 58 | my ($loc) = @_; 59 | return if exists $reachable{$loc}; 60 | 61 | $reachable{$loc} = 1; 62 | my $type = $map{$loc}{building}; 63 | my $str = $faction->{building_strength}{$type} // $building_strength{$type}; 64 | $power += $str; 65 | $count++; 66 | # Sanctuary counts as two buildings. 67 | $count++ if $map{$loc}{building} eq 'SA'; 68 | 69 | for my $adjacent (adjacent_own_buildings $faction, $loc) { 70 | $handle->($adjacent); 71 | } 72 | }; 73 | $handle->($where); 74 | $handle = undef; 75 | 76 | my @reachable = keys %reachable; 77 | my $town_tile_count = grep { /^TW/ and $game{pool}{$_} > 0 } keys %{$game{pool}}; 78 | if ($power >= $faction->{TOWN_SIZE} and $count >= 4 and 79 | $town_tile_count) { 80 | # Use the same town id for all towns for now. 81 | $map{$_}{town} = $town_tile_count for @reachable; 82 | adjust_resource($faction, "GAIN_TW", 1); 83 | return 1; 84 | } 85 | 86 | return 0; 87 | } 88 | 89 | sub check_mermaid_river_connection_town { 90 | my ($faction, $river) = @_; 91 | 92 | # Already a town bordering that river space. 93 | for my $adjacent (adjacent_own_buildings $faction, $river) { 94 | if ($map{$adjacent}{town}) { 95 | return 0; 96 | } 97 | } 98 | 99 | my %reachable = (); 100 | my $power = 0; 101 | my $count = 0; 102 | 103 | # Count the number and power of the buildings reachable from this 104 | # hex. 105 | my $handle; 106 | $handle = sub { 107 | my ($loc) = @_; 108 | return if exists $reachable{$loc}; 109 | 110 | $reachable{$loc} = 1; 111 | if ($map{$loc}{building}) { 112 | my $type = $map{$loc}{building}; 113 | my $str = $faction->{building_strength}{$type} // $building_strength{$type}; 114 | $power += $str; 115 | $count++; 116 | # Sanctuary counts as two buildings. 117 | $count++ if $type eq 'SA'; 118 | } 119 | 120 | for my $adjacent (adjacent_own_buildings $faction, $loc) { 121 | $handle->($adjacent); 122 | } 123 | }; 124 | $handle->($river); 125 | $handle = undef; 126 | 127 | if ($power >= $faction->{TOWN_SIZE} and $count >= 4 and 128 | grep { /^TW/ and $game{pool}{$_} > 0 } keys %{$game{pool}}) { 129 | return 1; 130 | } 131 | 132 | return 0; 133 | } 134 | 135 | sub update_mermaid_town_connections { 136 | my $mermaids = $game{acting}->factions()->{mermaids}; 137 | return if !$mermaids; 138 | 139 | my @valid_spaces = (); 140 | 141 | for my $river (keys %map) { 142 | next if $river !~ /^r/; 143 | if (check_mermaid_river_connection_town $mermaids, $river) { 144 | push @valid_spaces, $river; 145 | $map{$river}{possible_town} = 1; 146 | } 147 | } 148 | 149 | $mermaids->{possible_towns} = [ 150 | sort { natural_cmp $a, $b } @valid_spaces 151 | ]; 152 | } 153 | 154 | 1; 155 | -------------------------------------------------------------------------------- /src/Server/EditGame.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | no indirect; 3 | 4 | package Server::EditGame; 5 | 6 | use Moose; 7 | use Server::Server; 8 | use Method::Signatures::Simple; 9 | 10 | extends 'Server::Server'; 11 | 12 | use Digest::SHA qw(sha1_hex); 13 | 14 | use DB::Chat; 15 | use DB::Connection; 16 | use DB::EditLink; 17 | use DB::Game; 18 | use Server::Session; 19 | use Util::SiteConfig; 20 | 21 | has 'mode' => (is => 'ro', required => 1); 22 | 23 | method handle($q) { 24 | $self->no_cache(); 25 | 26 | my $dbh = get_db_connection; 27 | 28 | my $username = username_from_session_token($dbh, 29 | $q->cookie('session-token') // ''); 30 | 31 | if (!defined $username) { 32 | my $out = { 33 | error => ["Not logged in"], 34 | location => "/login/", 35 | }; 36 | $self->output_json($out); 37 | return; 38 | } 39 | 40 | my $write_id = $q->param('game'); 41 | $write_id =~ s{.*/}{}; 42 | $write_id =~ s{[^A-Za-z0-9_]}{}g; 43 | my ($read_id) = $write_id =~ /(.*)_/g; 44 | 45 | eval { 46 | $self->check_user_is_admin($dbh, $read_id, $username); 47 | $self->check_user_is_not_deadbeat($dbh, $read_id, $username); 48 | }; if ($@) { 49 | $self->output_json({ error => [ $@ ] }); 50 | return; 51 | } 52 | 53 | if ($self->mode() eq 'content') { 54 | $self->edit_content($dbh, $q, $read_id, $write_id, $username); 55 | } elsif ($self->mode() eq 'status') { 56 | $self->edit_status($dbh, $q, $read_id, $write_id, $username); 57 | } 58 | } 59 | 60 | method check_user_is_admin($dbh, $read_id, $username) { 61 | my ($game_admin) = $dbh->selectrow_array("select admin_user from game where id=?", 62 | { Slice => {} }, 63 | $read_id); 64 | 65 | if ($username ne $game_admin and 66 | $username ne $config{site_admin_username}) { 67 | die "Sorry, it appears you're not the game admin.\n" 68 | } 69 | } 70 | 71 | method check_user_is_not_deadbeat($dbh, $read_id, $username) { 72 | my ($dropped) = $dbh->selectall_arrayref("select faction_player from game_role where game=? and dropped", 73 | { Slice => {} }, 74 | $read_id); 75 | for my $record (@{$dropped}) { 76 | if ($record->{faction_player} && 77 | $username eq $record->{faction_player}) { 78 | die "Sorry, you're no longer allowed to admin this game\n" 79 | } 80 | } 81 | 82 | my %blacklist = map { ($_ => 1) } @{$config{blacklist}}; 83 | if ($blacklist{$username}) { 84 | die "Sorry, admin functionality disabled due to abuse.\n"; 85 | } 86 | } 87 | 88 | method edit_content($dbh, $q, $read_id, $write_id, $username) { 89 | my ($prefix_data, $data) = get_game_content $dbh, $read_id, $write_id; 90 | my $players = get_game_players($dbh, $read_id); 91 | my $metadata = get_game_metadata($dbh, $read_id); 92 | 93 | my $res = terra_mystica::evaluate_game { 94 | rows => [ split /\n/, "$prefix_data\n$data" ], 95 | faction_info => get_game_factions($dbh, $read_id), 96 | players => $players, 97 | metadata => $metadata, 98 | }; 99 | 100 | # Development hack 101 | if ($username eq $config{site_admin_username}) { 102 | for my $faction (values %{$res->{factions}}) { 103 | $faction->{edit_link} = edit_link_for_faction $dbh, $write_id, $faction->{name}; 104 | } 105 | } 106 | 107 | my $out = { 108 | data => $data, 109 | error => [], 110 | hash => sha1_hex($data), 111 | action_required => $res->{action_required}, 112 | players => $players, 113 | factions => $res->{factions}, 114 | metadata => $metadata, 115 | }; 116 | 117 | $self->output_json($out); 118 | } 119 | 120 | method edit_status($dbh, $q, $read_id, $write_id, $username) { 121 | my $action = $q->param('action'); 122 | my $res = { 123 | error => [], 124 | }; 125 | 126 | if ($action eq 'abort') { 127 | $res->{status} = 'aborted'; 128 | abort_game $dbh, $write_id; 129 | insert_chat_message($dbh, $read_id, 130 | 'admin', 131 | "Game was aborted by $username", 132 | ''); 133 | } elsif ($action eq 'unabort') { 134 | $res->{status} = 'restarted'; 135 | unabort_game $dbh, $write_id; 136 | insert_chat_message($dbh, $read_id, 137 | 'admin', 138 | "Game was restarted by $username", 139 | ''); 140 | } else { 141 | $res->{status} = 'error'; 142 | $res->{error} = [ "Invalid action '$action'" ]; 143 | } 144 | 145 | $self->output_json($res); 146 | } 147 | 148 | 1; 149 | -------------------------------------------------------------------------------- /src/ledger.pm: -------------------------------------------------------------------------------- 1 | package terra_mystica::Ledger; 2 | use Moose; 3 | use Method::Signatures::Simple; 4 | 5 | use resources; 6 | 7 | has 'game' => (is => 'rw', required => 1); 8 | 9 | # The data we're collecting 10 | has 'rows' => (is => 'rw', 11 | traits => ['Array'], 12 | default => sub { [] }, 13 | handles => { 14 | size => 'count', 15 | add_row => 'push', 16 | }); 17 | 18 | # Information related to adding turn / round comments at the right place 19 | has ['last_printed_round', 20 | 'last_printed_turn'] 21 | => (is => 'rw', isa => 'Int', default => 0); 22 | has 'trailing_comment' => (is => 'rw', isa => 'Bool'); 23 | 24 | # Data collected about the current row so far 25 | has 'collecting_row' => (is => 'rw', default => 0); 26 | has 'current_faction' => (is => 'rw'); 27 | has 'commands' => (is => '', 28 | traits => ['Array'], 29 | default => sub { [] }, 30 | handles => { 31 | commands => 'elements', 32 | add_command => 'push', 33 | clear_commands => 'clear', 34 | join_commands => 'join', 35 | }); 36 | has 'force_finish_row' => (is => 'rw', default => 0); 37 | has 'start_resources' => (is => 'rw'); 38 | has 'warnings' => (is => '', 39 | traits => ['Array'], 40 | default => sub { [] }, 41 | handles => { 42 | warn => 'push', 43 | clear_warnings => 'clear', 44 | warnings => 'elements', 45 | first_warning => [ get => 0 ], 46 | }); 47 | has 'leech' => (is => 'rw', default => sub { {} }); 48 | 49 | my @data_fields = qw(VP C W P P1 P2 P3 PW FIRE WATER EARTH AIR CULT); 50 | 51 | after add_row => sub { 52 | my ($self, $row) = @_; 53 | $self->trailing_comment(0); 54 | }; 55 | 56 | method start_new_row($faction) { 57 | return if $self->collecting_row(); 58 | 59 | $self->collecting_row(1); 60 | $self->current_faction($faction); 61 | $self->force_finish_row(0); 62 | $self->clear_commands(); 63 | $self->clear_warnings(); 64 | $self->leech({}); 65 | $self->start_resources( 66 | { map { ( $_, $faction->{$_}) } @data_fields }); 67 | } 68 | 69 | before add_command => sub { 70 | my ($self, $command) = @_; 71 | die if !$self->collecting_row(); 72 | }; 73 | 74 | method report_leech($faction_name, $amount) { 75 | $self->leech()->{$faction_name} += $amount; 76 | } 77 | 78 | method finish_row { 79 | return if !$self->collecting_row(); 80 | 81 | my $faction = $self->current_faction(); 82 | 83 | # Compute the delta 84 | my %end_resources = map { $_, $faction->{$_} } @data_fields; 85 | my %pretty_delta = (); 86 | 87 | if ($faction->{dummy}) { 88 | %pretty_delta = map { ($_, 0) } @data_fields; 89 | } else { 90 | %pretty_delta = terra_mystica::pretty_resource_delta($self->start_resources(), 91 | \%end_resources); 92 | } 93 | 94 | my $info = { faction => $faction->{name}, 95 | leech => $self->leech(), 96 | warning => $self->first_warning() // "", 97 | commands => $self->join_commands(". "), 98 | map { $_, $pretty_delta{$_} } @data_fields}; 99 | 100 | my $row_summary = "$faction->{name}: $info->{commands}"; 101 | 102 | my $game = $self->game(); 103 | 104 | if (!$game->{finished}) { 105 | for my $f ($game->{acting}->factions_in_order()) { 106 | push @{$f->{recent_moves}}, $row_summary; 107 | } 108 | } 109 | 110 | $self->add_row($info); 111 | $self->collecting_row(0); 112 | } 113 | 114 | method add_comment($comment) { 115 | $self->add_row({ comment => $comment }); 116 | $self->trailing_comment(1); 117 | } 118 | 119 | method add_row_for_effect($faction, $command, $fun) { 120 | my %old_data = map { $_, $faction->{$_} } @data_fields; 121 | $fun->($faction); 122 | my %new_data = map { $_, $faction->{$_} } @data_fields; 123 | my %pretty_delta = terra_mystica::pretty_resource_delta(\%old_data, \%new_data); 124 | 125 | $self->add_row({ 126 | faction => $faction->{name}, 127 | commands => $command, 128 | map { $_, $pretty_delta{$_} } @data_fields 129 | }); 130 | 131 | } 132 | 133 | method turn($round, $turn) { 134 | if ($round == $self->last_printed_round() and 135 | $turn == $self->last_printed_turn()) { 136 | return; 137 | } 138 | 139 | $self->last_printed_turn($turn); 140 | $self->last_printed_round($round); 141 | 142 | return if $self->{trailing_comment}; 143 | 144 | $self->add_comment("Round $round, turn $turn"); 145 | } 146 | 147 | method flush { 148 | $self->finish_row(); 149 | $self->rows(); 150 | } 151 | 152 | 153 | 1; 154 | -------------------------------------------------------------------------------- /src/Server/Register.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Register; 4 | 5 | use Moose; 6 | use Server::Server; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); 12 | use Net::SMTP; 13 | 14 | use Bytes::Random::Secure qw(random_bytes); 15 | use DB::Connection; 16 | use DB::Secret; 17 | use DB::Validation; 18 | use Server::Session; 19 | use Util::CryptUtil; 20 | use Util::PasswordQuality; 21 | use Util::SiteConfig; 22 | 23 | has 'mode' => (is => 'ro', required => 1); 24 | 25 | method handle($q, $suffix) { 26 | $self->no_cache(); 27 | my $dbh = get_db_connection; 28 | my $mode = $self->mode(); 29 | 30 | if ($mode eq 'validate') { 31 | $self->validate_registration($q, $dbh, $suffix); 32 | } elsif ($mode eq 'request') { 33 | $self->request_registration($q, $dbh); 34 | } else { 35 | die "Unknown mode $mode"; 36 | } 37 | } 38 | 39 | method request_registration($q, $dbh) { 40 | my @error = (); 41 | 42 | my $username = $q->param('username'); 43 | my $email = $q->param('email'); 44 | my $password = $q->param('password1'); 45 | 46 | if ($username =~ /([^A-Za-z0-9._-])/) { 47 | push @error, "Invalid character in username '$1'" 48 | } 49 | 50 | if (!@error) { 51 | my ($username_in_use) = $dbh->selectrow_array("select count(*) from player where lower(username) = lower(?)", {}, $username); 52 | my ($email_in_use) = $dbh->selectrow_array("select count(*) from email where lower(address) = lower(?)", {}, $email); 53 | 54 | if ($username_in_use) { 55 | push @error, "The username is already in use"; 56 | } 57 | 58 | if ($email_in_use) { 59 | push @error, "The email address is already registered"; 60 | } 61 | } 62 | 63 | if (!@error) { 64 | my ($reason) = password_too_weak $username, $password; 65 | if ($reason) { 66 | push @error, "Bad password: $reason\n"; 67 | } 68 | } 69 | 70 | if (!@error) { 71 | my $random = Bytes::Random::Secure->new( 72 | Bits => 512, 73 | NonBlocking => 1, 74 | ); 75 | my $salt = en_base64 $random->bytes(16); 76 | my $hashed_password = bcrypt($password, 77 | '$2a$08$'.$salt); 78 | 79 | my $data = { 80 | username => $username, 81 | email => $email, 82 | hashed_password => $hashed_password 83 | }; 84 | my $token = insert_to_validate $dbh, $data; 85 | 86 | my $url = sprintf "https://$config{domain}/app/register/validate/%s", $token; 87 | 88 | my $smtp = Net::SMTP->new('localhost', ( Debug => 0 )); 89 | 90 | $smtp->mail("www-data\@$config{email_domain}"); 91 | if (!$smtp->to($email)) { 92 | push @error, "Invalid email address"; 93 | } else { 94 | $smtp->data(); 95 | $smtp->datasend("To: $email\n"); 96 | $smtp->datasend("From: noreply+registration\@$config{email_domain}\n"); 97 | $smtp->datasend("Subject: Account activation for Terra Mystica\n"); 98 | $smtp->datasend("\n"); 99 | $smtp->datasend("To activate your account, use the following link:\n"); 100 | $smtp->datasend(" $url\n"); 101 | $smtp->dataend(); 102 | } 103 | 104 | $smtp->quit; 105 | } 106 | 107 | $self->output_json({ error => [@error] }); 108 | } 109 | 110 | method validate_registration($q, $dbh, $suffix) { 111 | my $token = $suffix // $q->param('token'); 112 | 113 | eval { 114 | my @data = (); 115 | my $payload = fetch_validate_payload $dbh, $token; 116 | @data = ($payload->{username}, $payload->{email}, 117 | $payload->{hashed_password}); 118 | 119 | my $already_done = $self->register($dbh, @data); 120 | if ($already_done) { 121 | $self->output_html("

Account already exists

"); 122 | } else { 123 | $self->output_html( "

Account created

"); 124 | } 125 | }; if ($@) { 126 | print STDERR "token: $token\n"; 127 | print STDERR $@; 128 | $self->output_html( "

Validation failed

"); 129 | } 130 | } 131 | 132 | method register($dbh, $user, $email, $hashed_password) { 133 | my ($already_done) = $dbh->selectrow_array("select count(*) from email where lower(address) = lower(?) and player = ?", {}, $email, $user); 134 | 135 | if (!$already_done) { 136 | $dbh->do('begin'); 137 | $dbh->do('insert into player (username, displayname, password) values (?, ?, ?)', {}, 138 | $user, $user, $hashed_password); 139 | $dbh->do('insert into email (address, player, validated, is_primary) values (lower(?), ?, ?, true)', 140 | {}, $email, $user, 1); 141 | $dbh->do('commit'); 142 | } 143 | 144 | return $already_done; 145 | } 146 | 147 | 1; 148 | -------------------------------------------------------------------------------- /src/DB/AddGames.pm: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | package DB::AddGames; 4 | 5 | use Exporter::Easy ( 6 | EXPORT => [qw(validate make_games)] 7 | ); 8 | 9 | use strict; 10 | no indirect; 11 | 12 | use DBI; 13 | use JSON; 14 | 15 | use DB::Connection; 16 | use DB::Game; 17 | use DB::SaveGame; 18 | use DB::UserValidate; 19 | use Email::Notify; 20 | 21 | sub validate { 22 | my ($dbh, $desc) = @_; 23 | my $games = $desc->{games}; 24 | my $options = $desc->{options}; 25 | 26 | my %order = (); 27 | my %count = (); 28 | 29 | for my $game_desc (@{$games}) { 30 | my $id = $game_desc->{name}; 31 | my @players = @{$game_desc->{players}}; 32 | for (my $i = 0; $i < @players; ++$i) { 33 | my $player = $players[$i]; 34 | if ($order{$player}{$i}) { 35 | die "$player on position $i in multiple games ($order{$player}{$i}, $id)\n" 36 | } 37 | $order{$player}{$i} = $id; 38 | $count{$player}++; 39 | die "Someone trying to add a test account\n" if $player eq 'test' or $player eq 'test2' or $player eq 'TestCapital'; 40 | } 41 | } 42 | 43 | for my $player (keys %count) { 44 | if ($count{$player} != $desc->{'games-per-player'}) { 45 | die "$player playing in $count{$player} matches (wanted $desc->{'games-per-player'})\n" 46 | } 47 | check_username_is_registered $dbh, $player; 48 | } 49 | 50 | my $chess_clock_settings = scalar grep { defined $_ } map { $desc->{$_} } qw(chess-clock-hours-initial chess-clock-hours-per-round chess-clock-grace-period); 51 | if (($chess_clock_settings != 0 and $chess_clock_settings != 3) or 52 | ($chess_clock_settings == 0 and !defined $desc->{'deadline-hours'}) or 53 | ($chess_clock_settings == 3 and defined $desc->{'deadline-hours'})) { 54 | die "Inconsistent chess clock / deadline settings\n"; 55 | } 56 | 57 | check_username_is_registered $dbh, $desc->{admin}; 58 | 59 | my %valid_options = map { ($_, 1) } qw( 60 | errata-cultist-power 61 | mini-expansion-1 62 | shipping-bonus 63 | temple-scoring-tile 64 | email-notify 65 | maintain-player-order 66 | strict-leech 67 | strict-chaosmagician-sh 68 | strict-darkling-sh 69 | variable-turn-order); 70 | for my $opt (@{$options}) { 71 | if (!$valid_options{$opt}) { 72 | die "Unknown option $opt\n"; 73 | } 74 | } 75 | } 76 | 77 | sub make_games { 78 | my ($dbh, $desc) = @_; 79 | my $games = $desc->{games}; 80 | my $options = $desc->{options}; 81 | my $player_count = undef; 82 | my $admin = $desc->{admin}; 83 | 84 | my @to_create = (); 85 | 86 | for my $game_desc (@{$games}) { 87 | my $id = $game_desc->{name}; 88 | if (game_exists $dbh, $id) { 89 | print "Game $id already exists, skipping\n"; 90 | } else { 91 | push @to_create, $game_desc; 92 | } 93 | } 94 | 95 | my $map_variant = undef; 96 | 97 | for my $game_desc (@to_create) { 98 | $dbh->do("begin"); 99 | my $id = $game_desc->{name}; 100 | my @players = map { 101 | my $player = $_; 102 | my ($username, $email) = 103 | check_username_is_registered $dbh, $player; 104 | { email => $email, username => $username } 105 | } @{$game_desc->{players}}; 106 | 107 | print "Creating $id with @{$game_desc->{players}}\n"; 108 | 109 | create_game($dbh, 110 | $id, 111 | $admin, 112 | [ @players ], 113 | $player_count, 114 | $map_variant, 115 | @{$options}); 116 | 117 | 118 | 119 | $dbh->do("update game set description=?, current_chess_clock_hours=? where id=?", 120 | {}, 121 | $game_desc->{'description'}, 122 | $desc->{'chess-clock-hours-initial'}, 123 | $id); 124 | 125 | $dbh->do("insert into game_options (game, description, minimum_rating, maximum_rating, deadline_hours, chess_clock_hours_initial, chess_clock_hours_per_round, chess_clock_grace_period) values (?, ?, ?, ?, ?, ?, ?, ?)", 126 | {}, 127 | $id, 128 | $game_desc->{'description'}, 129 | $desc->{'minimum-rating'}, 130 | $desc->{'maximum-rating'}, 131 | $desc->{'deadline-hours'}, 132 | $desc->{'chess-clock-hours-initial'}, 133 | $desc->{'chess-clock-hours-per-round'}, 134 | $desc->{'chess-clock-grace-period'}); 135 | 136 | notify_game_started $dbh, { 137 | name => $id, 138 | options => { map { ($_ => 1) } @{$options} }, 139 | players => [ values %{get_game_factions($dbh, $id)} ], 140 | }; 141 | 142 | $dbh->do("commit"); 143 | 144 | sleep 1; 145 | } 146 | } 147 | 148 | 1; 149 | -------------------------------------------------------------------------------- /src/Server/Map.pm: -------------------------------------------------------------------------------- 1 | use strict; 2 | 3 | package Server::Map; 4 | 5 | use Digest::SHA qw(sha1_hex); 6 | use Moose; 7 | use Method::Signatures::Simple; 8 | 9 | extends 'Server::Server'; 10 | 11 | use Analyze::EloVpPredictor; 12 | use DB::Connection qw(get_db_connection); 13 | use DB::Game; 14 | use Game::Constants; 15 | use map; 16 | use Server::Security; 17 | use Server::Session; 18 | use tracker; 19 | use Util::SiteConfig; 20 | 21 | has 'mode' => (is => 'ro', required => 1); 22 | 23 | method handle($q, $id) { 24 | $self->no_cache(); 25 | 26 | my $dbh = get_db_connection; 27 | 28 | my $base_map = $q->param('base_map'); 29 | 30 | my $res = { 31 | error => [], 32 | bridges => [], 33 | }; 34 | 35 | if ($self->mode() eq 'view') { 36 | view($dbh, $id, $res, $q->param('map-only') // 1); 37 | } else { 38 | my $username = username_from_session_token( 39 | $dbh, 40 | $q->cookie('session-token') // ''); 41 | 42 | if (!defined $username) { 43 | $self->output_json({ 44 | error => [ "Not logged in\n" ] 45 | }); 46 | return; 47 | } 48 | 49 | if ($self->mode() eq 'preview') { 50 | preview($dbh, $q->param('map-data'), $res); 51 | } elsif ($self->mode() eq 'save') { 52 | save($dbh, $q->param('map-data'), $res, $username); 53 | } 54 | } 55 | 56 | $self->output_json($res); 57 | }; 58 | 59 | func convert_to_lodev($base_map) { 60 | $base_map =~ s/\s+/ /g; 61 | $base_map =~ s/\s*E\s*/;\n/g; 62 | $base_map =~ s/black/K/g; 63 | $base_map =~ s/blue/B/g; 64 | $base_map =~ s/brown/U/g; 65 | $base_map =~ s/green/G/g; 66 | $base_map =~ s/gray/S/g; 67 | $base_map =~ s/red/R/g; 68 | $base_map =~ s/yellow/Y/g; 69 | $base_map =~ s/x/I/g; 70 | $base_map =~ s/ /,/g; 71 | $base_map; 72 | } 73 | 74 | func convert_from_lodev($base_map) { 75 | if ($base_map =~ /^N/) { 76 | $base_map = ";$base_map"; 77 | } 78 | $base_map =~ s/N,?//g; 79 | 80 | $base_map =~ s/K/black/g; 81 | $base_map =~ s/B/blue/g; 82 | $base_map =~ s/U/brown/g; 83 | $base_map =~ s/G/green/g; 84 | $base_map =~ s/S/gray/g; 85 | $base_map =~ s/R/red/g; 86 | $base_map =~ s/Y/yellow/g; 87 | $base_map =~ s/I/x/g; 88 | $base_map =~ s/;\s*/ E /g; 89 | $base_map =~ s/^ +//g; 90 | $base_map =~ s/ +$//g; 91 | $base_map =~ s/,/ /g; 92 | $base_map =~ s/ +/ /g; 93 | $base_map; 94 | } 95 | 96 | func preview($dbh, $mapdata, $res) { 97 | my $map_str = convert_from_lodev($mapdata); 98 | my $base_map = [ split /\s+/, $map_str ]; 99 | my $map = terra_mystica::setup_map $base_map; 100 | 101 | my $id = sha1_hex $map_str; 102 | 103 | $res->{'map'} = $map; 104 | $res->{'mapdata'} = $mapdata; 105 | $res->{'mapid'} = $id; 106 | $res->{'saved'} = map_exists($dbh, $id); 107 | } 108 | 109 | func map_exists($dbh, $id) { 110 | my ($count) = $dbh->selectrow_array("select count(*) from map_variant where id=?", 111 | {}, 112 | $id); 113 | $count ? 1 : 0; 114 | } 115 | 116 | func save($dbh, $mapdata, $res, $username) { 117 | if ($username ne $config{site_admin_username} and $username ne 'nan') { 118 | die "Sorry, creating new maps isn't allowed\n" 119 | } 120 | 121 | my $map_str = convert_from_lodev($mapdata); 122 | my $id = sha1_hex $map_str; 123 | 124 | if (!map_exists($dbh, $id)) { 125 | $dbh->do("insert into map_variant (id, terrain) values (?, ?)", 126 | {}, 127 | $id, $map_str); 128 | } 129 | 130 | $res->{'mapid'} = sha1_hex $map_str; 131 | } 132 | 133 | func view($dbh, $id, $res, $map_only) { 134 | my ($map_str, $vp_variant) = $dbh->selectrow_array("select terrain, vp_variant from map_variant where id=?", {}, $id); 135 | my $base_map = [ split /\s+/, $map_str ]; 136 | my $map = terra_mystica::setup_map $base_map; 137 | 138 | $res->{'map'} = $map; 139 | $res->{'mapdata'} = convert_to_lodev($map_str); 140 | $res->{'mapid'} = $id; 141 | 142 | if ($id ne '224736500d20520f195970eb0fd4c41df040c08c' and 143 | $id ne '54919e13090127079e7cc3540ad0065311f2ecd7' and 144 | $id ne '2afadc63f4d81e850b7c16fb21a1dcd29658c392') { 145 | $map_only = 1; 146 | } 147 | 148 | if (!$map_only) { 149 | my $game_ids = $dbh->selectall_arrayref("select id, round, finished, array (select faction || ' ' || vp from game_role where game=game.id order by vp desc) as factions from game where base_map=? and player_count > 2 and not aborted order by finished, round, id", 150 | { Slice => {} }, 151 | $id); 152 | 153 | $res->{'games'} = $game_ids; 154 | 155 | $res->{'vpstats'} = faction_vp_error_by_map $dbh, $id; 156 | } 157 | 158 | if ($vp_variant) { 159 | $res->{vp_setup} = $Game::Constants::vp_setups{$vp_variant}; 160 | } 161 | } 162 | 163 | 1; 164 | --------------------------------------------------------------------------------