mirror of
https://github.com/pragma-/pbot.git
synced 2024-12-23 19:22:40 +01:00
926d57990b
- convert several plugins to use named-parameters - misc clean-ups in unrelated files
1532 lines
48 KiB
Perl
1532 lines
48 KiB
Perl
# File: Battleship.pm
|
|
#
|
|
# Purpose: Simplified version of the Battleship board game. In this variant,
|
|
# there is one game grid/board and every player's ships share it without
|
|
# overlapping. This adds an element of strategy: everybody knows where their
|
|
# own ships are located, ergo they know where NOT to aim. This helps to speed
|
|
# games up by removing some randomness.
|
|
#
|
|
# Note: This code was written circa 1993 for a DikuMUD fork. It was originally
|
|
# written in C, as I was teaching the language to myself in my early teens. Two
|
|
# decades or so later, I transliterated this code from C to Perl for PBot. Much
|
|
# of the "ugly" C-style design of this code has been preserved for personal
|
|
# historical reasons -- I was inspired by the IOCCC and I attempted to be clever
|
|
# with nested conditional operators and other silliness. Please be gentle if you
|
|
# read this code. :)
|
|
#
|
|
# Update: Much of this code has now been refactored to support more than two
|
|
# players on a single board. The board grows in size for each additional player,
|
|
# to accomodate their ships. Whirlpools have also been added. They are initially
|
|
# hidden by the ocean. When shot, they reveal themselves on the map and deflect
|
|
# the shot to a random tile. Much of the IOCCC silliness has been removed so that
|
|
# I can maintain this code without going insane.
|
|
|
|
# SPDX-FileCopyrightText: 2021 Pragmatic Software <pragma78@gmail.com>
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
package PBot::Plugin::Battleship;
|
|
use parent 'PBot::Plugin::Base';
|
|
|
|
use PBot::Imports;
|
|
|
|
use Time::Duration;
|
|
use Data::Dumper;
|
|
|
|
# some colors for IRC messages
|
|
my %color = (
|
|
white => "\x0300",
|
|
black => "\x0301",
|
|
blue => "\x0302",
|
|
green => "\x0303",
|
|
red => "\x0304",
|
|
maroon => "\x0305",
|
|
purple => "\x0306",
|
|
orange => "\x0307",
|
|
yellow => "\x0308",
|
|
lightgreen => "\x0309",
|
|
teal => "\x0310",
|
|
cyan => "\x0311",
|
|
lightblue => "\x0312",
|
|
magneta => "\x0313",
|
|
gray => "\x0314",
|
|
lightgray => "\x0315",
|
|
|
|
bold => "\x02",
|
|
italics => "\x1D",
|
|
underline => "\x1F",
|
|
reverse => "\x16",
|
|
|
|
reset => "\x0F",
|
|
);
|
|
|
|
sub initialize {
|
|
my ($self, %conf) = @_;
|
|
|
|
# register `battleship` bot command
|
|
$self->{pbot}->{commands}->add(
|
|
name => 'battleship',
|
|
help => 'Battleship board game, simplified for IRC',
|
|
subref => sub { $self->cmd_battleship(@_) },
|
|
);
|
|
|
|
# set the channel where to send game messages
|
|
$self->{channel} = $self->{pbot}->{registry}->get_value('battleship', 'channel') // '##battleship';
|
|
|
|
# debugging flag
|
|
$self->{debug} = $self->{pbot}->{registry}->get_value('battleship', 'debug') // 0;
|
|
|
|
# player limit per game
|
|
$self->{MAX_PLAYERS} = 5;
|
|
|
|
# max missed moves before player is ejected from game
|
|
$self->{MAX_MISSED_MOVES} = 5;
|
|
|
|
# types of board tiles
|
|
$self->{TYPE_OCEAN} = 0;
|
|
$self->{TYPE_WHIRLPOOL} = 1;
|
|
$self->{TYPE_SHIP} = 2;
|
|
|
|
# battleship tile symbols
|
|
$self->{TILE_HIT} = ['1' .. $self->{MAX_PLAYERS}];
|
|
$self->{TILE_OCEAN} = "$color{blue}~";
|
|
$self->{TILE_MISS} = "$color{cyan}o";
|
|
$self->{TILE_WHIRLPOOL} = "$color{cyan}@";
|
|
|
|
# personal ship tiles shown on player board
|
|
$self->{TILE_SHIP_VERT} = "$color{white}|";
|
|
$self->{TILE_SHIP_HORIZ} = "$color{white}—";
|
|
|
|
# all player ship tiles shown on final/full board
|
|
$self->{TILE_SHIP} = ['A' .. chr ord('A') + $self->{MAX_PLAYERS} - 1];
|
|
|
|
# default board dimensions
|
|
$self->{BOARD_X} = 12;
|
|
$self->{BOARD_Y} = 8;
|
|
|
|
# number of ships per player
|
|
$self->{SHIP_COUNT} = 6;
|
|
|
|
# modifiers for show_battlefield()
|
|
$self->{BOARD_SPECTATOR} = -1;
|
|
$self->{BOARD_FINAL} = -2;
|
|
$self->{BOARD_FULL} = -3;
|
|
|
|
# ship orientation
|
|
$self->{ORIENT_VERT} = 0;
|
|
$self->{ORIENT_HORIZ} = 1;
|
|
|
|
# paused state (0 is unpaused)
|
|
$self->{PAUSED_BY_PLAYER} = 1;
|
|
$self->{PAUSED_FOR_OUTPUT_QUEUE} = 2;
|
|
|
|
# create game state machine
|
|
$self->create_states;
|
|
|
|
# receive notification when all messages in IRC output queue have been sent
|
|
$self->{pbot}->{event_dispatcher}->register_handler(
|
|
'pbot.output_queue_empty', sub { $self->on_output_queue_empty(@_) }
|
|
);
|
|
}
|
|
|
|
sub unload {
|
|
my ($self) = @_;
|
|
|
|
# unregister `battleship` bot command
|
|
$self->{pbot}->{commands}->remove('battleship');
|
|
|
|
# remove battleship loop event from event queue
|
|
$self->end_game_loop;
|
|
|
|
# remove event handler
|
|
$self->{pbot}->{event_dispatcher}->remove_handler('pbot.output_queue_empty');
|
|
}
|
|
|
|
# the game is paused at the beginning when sending the player boards to all
|
|
# the players and then resumed when the output queue has depleted. this prevents
|
|
# game events from queuing up while the board messages are being slowly
|
|
# trickled out to the ircd to avoid filling up its message queue (and getting
|
|
# disconnected with 'excess flood'). this event handler resumes the game once
|
|
# the boards have finished transmitting, unless the game was manually paused
|
|
# by a player.
|
|
sub on_output_queue_empty {
|
|
my ($self) = @_; # we don't care about the other event arguments
|
|
|
|
# if we're paused waiting for the output queue, go ahead and unpause
|
|
if ($self->{state_data}->{paused} == $self->{PAUSED_FOR_OUTPUT_QUEUE}) {
|
|
$self->{state_data}->{paused} = 0;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
# `battleship` bot command
|
|
sub cmd_battleship {
|
|
my ($self, $context) = @_;
|
|
|
|
my $usage = "Usage: battleship challenge|accept|decline|ready|unready|bomb|board|score|players|pause|quit|kick|abort; see also: battleship help <command>";
|
|
|
|
# strip leading and trailing whitespace
|
|
$context->{arguments} =~ s/^\s+|\s+$//g;
|
|
|
|
my ($command, $arguments) = split / /, $context->{arguments}, 2;
|
|
|
|
$command //= '';
|
|
$command = lc $command;
|
|
|
|
$arguments //= '';
|
|
$arguments = lc $arguments;
|
|
|
|
# shorter aliases
|
|
my ($nick, $user, $host, $hostmask, $channel) = (
|
|
$context->{nick},
|
|
$context->{user},
|
|
$context->{host},
|
|
$context->{hostmask},
|
|
$self->{channel},
|
|
);
|
|
|
|
given ($command) {
|
|
# help doesn't do much yet
|
|
when ('help') {
|
|
given ($arguments) {
|
|
when ('help') {
|
|
return "Seriously?";
|
|
}
|
|
|
|
default {
|
|
if (length $arguments) {
|
|
return "Battleship help is coming soon.";
|
|
} else {
|
|
return "Usage: battleship help <command>";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# issue a challenge to begin a game
|
|
when ('challenge') {
|
|
if ($self->{current_state} ne 'nogame') {
|
|
return "There is already a game of Battleship underway.";
|
|
}
|
|
|
|
# set game to the `challenge` state to begin accepting challenge
|
|
$self->set_state('challenge');
|
|
|
|
# add player 0, the challenger, to the game
|
|
my $id = $self->get_player_id($nick, $user, $host);
|
|
|
|
my $player = $self->new_player($id, $nick);
|
|
|
|
# clear out player data
|
|
$self->{state_data}->{players} = [];
|
|
|
|
# add player 0
|
|
push @{$self->{state_data}->{players}}, $player;
|
|
|
|
# start the battleship game loop
|
|
$self->begin_game_loop;
|
|
|
|
return "/msg $channel $nick has issued a Battleship challenge! Use `accept` to accept their challenge.";
|
|
}
|
|
|
|
# accept a challenge
|
|
when (['accept', 'join']) {
|
|
if ($self->{current_state} ne 'challenge') {
|
|
return "This is not the time to use `$command`.";
|
|
}
|
|
|
|
if (@{$self->{state_data}->{players}} >= $self->{MAX_PLAYERS}) {
|
|
return "/msg $channel $nick: The player limit has been reached. Try again next game.";
|
|
}
|
|
|
|
my $id = $self->get_player_id($nick, $user, $host);
|
|
|
|
# check that player hasn't already accepted/joined
|
|
if (grep { $_->{id} == $id } @{$self->{state_data}->{players}}) {
|
|
return "$nick: You have already joined this Battleship game.";
|
|
}
|
|
|
|
# add another player
|
|
my $player = $self->new_player($id, $nick);
|
|
|
|
$player->{index} = @{$self->{state_data}->{players}};
|
|
|
|
push @{$self->{state_data}->{players}}, $player;
|
|
|
|
return "/msg $channel $nick has joined the game. Use `ready` to ready-up.";
|
|
}
|
|
|
|
# ready/unready
|
|
when (['ready', 'unready']) {
|
|
if ($self->{current_state} ne 'challenge') {
|
|
return "This is not the time to use `$command`.";
|
|
}
|
|
|
|
my $id = $self->get_player_id($nick, $user, $host);
|
|
|
|
my ($player) = grep { $_->{id} == $id } @{$self->{state_data}->{players}};
|
|
|
|
if (not defined $player) {
|
|
return "$nick: You have not joined this game of Battleship. Use `accept` to join the game.";
|
|
}
|
|
|
|
if ($command eq 'ready') {
|
|
$player->{ready} = 1;
|
|
return "/msg $channel $nick is ready!";
|
|
} else {
|
|
$player->{ready} = 0;
|
|
return "/msg $channel $nick is no longer ready.";
|
|
}
|
|
}
|
|
|
|
# decline a challenge or forfeit/concede a game
|
|
when (['decline', 'quit', 'forfeit', 'concede']) {
|
|
my $id = $self->get_player_id($nick, $user, $host);
|
|
|
|
for (my $i = 0; $i < @{$self->{state_data}->{players}}; $i++) {
|
|
if ($self->{state_data}->{players}->[$i]->{id} == $id) {
|
|
if ($self->{current_state} eq 'challenge') {
|
|
# remove from player list now since this is only the accept
|
|
# stage and a game hasn't yet begun
|
|
splice @{$self->{state_data}->{players}}, $i, 1;
|
|
} else {
|
|
# there is an on-going game, just mark them as removed
|
|
$self->{state_data}->{players}->[$i]->{removed} = 1;
|
|
}
|
|
|
|
return "/msg $channel $nick has left the game!";
|
|
}
|
|
}
|
|
|
|
return "There is nothing to $command.";
|
|
}
|
|
|
|
when ('abort') {
|
|
if (not $self->{pbot}->{users}->loggedin_admin($channel, $hostmask)) {
|
|
return "$nick: Only admins may abort the game.";
|
|
}
|
|
|
|
if ($self->{current_state} eq 'nogame') {
|
|
return "/msg $channel $nick: There is no ongoing game to abort.";
|
|
}
|
|
|
|
# jump directly to the `gameover` state to
|
|
# show the final board and reset the game
|
|
$self->set_state('gameover');
|
|
|
|
return "/msg $channel $nick: The game has been aborted.";
|
|
}
|
|
|
|
when (['pause', 'unpause']) {
|
|
if ($command eq 'pause') {
|
|
$self->{state_data}->{paused} = $self->{PAUSED_BY_PLAYER};
|
|
} else {
|
|
$self->{state_data}->{paused} = 0;
|
|
}
|
|
|
|
return "/msg $channel $nick has " . ($self->{state_data}->{paused} ? 'paused' : 'unpaused') . " the game!";
|
|
}
|
|
|
|
when ('score') {
|
|
if ($self->{current_state} ne 'move' and $self->{current_state} ne 'attack') {
|
|
return "There is no Battleship score to show right now.";
|
|
}
|
|
|
|
$self->show_scoreboard;
|
|
return '';
|
|
}
|
|
|
|
when ('players') {
|
|
if (not @{$self->{state_data}->{players}}) {
|
|
return "There are no players playing Battleship right now. Start a game with the `challenge` command!";
|
|
}
|
|
|
|
$self->list_players;
|
|
return '';
|
|
}
|
|
|
|
when ('kick') {
|
|
if (not $self->{pbot}->{users}->loggedin_admin($channel, $hostmask)) {
|
|
return "$nick: Only admins may kick players from the game.";
|
|
}
|
|
|
|
if (not length $arguments) {
|
|
return "Usage: battleship kick <nick>";
|
|
}
|
|
|
|
# get the id associated with this nick, in case the current player has changed nick while playing
|
|
my ($id) = $self->{pbot}->{messagehistory}->{database}->find_message_account_by_nick($arguments);
|
|
|
|
if (not defined $id) {
|
|
return "I don't know anybody named $arguments.";
|
|
}
|
|
|
|
$id = $self->{pbot}->{messagehistory}->{database}->get_ancestor_id($id);
|
|
|
|
for (my $i = 0; $i < @{$self->{state_data}->{players}}; $i++) {
|
|
if (lc $self->{state_data}->{players}->[$i]->{id} == $id) {
|
|
$self->{state_data}->{players}->[$i]->{removed} = 1;
|
|
return "/msg $channel $nick: $arguments has been kicked from the game.";
|
|
}
|
|
}
|
|
|
|
return "$nick: $arguments isn't even in the game.";
|
|
}
|
|
|
|
when ('bomb') {
|
|
if ($self->{current_state} ne 'move' and $self->{current_state} ne 'attack') {
|
|
return "$nick: It's not time to do that now.";
|
|
}
|
|
|
|
my $id = $self->get_player_id($nick, $user, $host);
|
|
|
|
my ($player) = grep { $_->{id} == $id } @{$self->{state_data}->{players}};
|
|
|
|
if (not defined $player) {
|
|
return "You are not playing in this game.";
|
|
}
|
|
|
|
# no arguments provided
|
|
if (not length $arguments) {
|
|
if (delete $player->{location}) {
|
|
return "$nick: Attack location cleared.";
|
|
} else {
|
|
return "$nick: Usage: bomb <location>";
|
|
}
|
|
}
|
|
|
|
# validate arguments
|
|
$arguments = uc $arguments;
|
|
|
|
if ($arguments !~ m/^[A-Z][0-9]+$/) {
|
|
return "$nick: Usage: bomb <location>; <location> must be in the form of A15, B3, C9, etc.";
|
|
}
|
|
|
|
# ensure arguments are within range of battlefield
|
|
my ($x, $y) = $arguments =~ m/^(.)(.*)/;
|
|
|
|
$x = ord($x) - 65;
|
|
|
|
if ($x < 0 || $x > $self->{N_Y} || $y < 0 || $y > $self->{N_X}) {
|
|
return "$nick: Target out of range, try again.";
|
|
}
|
|
|
|
my $msg;
|
|
if (not exists $player->{location}) {
|
|
$msg = "/msg $channel $nick aims somewhere.";
|
|
}
|
|
elsif (lc $player->{location} eq lc $arguments) {
|
|
return '';
|
|
}
|
|
else {
|
|
$msg = "/msg $channel $nick aims somewhere else.";
|
|
}
|
|
$player->{location} = $arguments;
|
|
return $msg;
|
|
}
|
|
|
|
when (['specboard', 'board']) {
|
|
if (grep { $_ eq $self->{current_state} } qw/nogame challenge genboard gameover/) {
|
|
return "$nick: There is no board to show right now.";
|
|
}
|
|
|
|
# specifically show spectator board, even if invoked by a player
|
|
if ($_ eq 'specboard') {
|
|
$self->show_battlefield($self->{BOARD_SPECTATOR});
|
|
return '';
|
|
}
|
|
|
|
my $id = $self->get_player_id($nick, $user, $host);
|
|
|
|
# show player's personal board if playing
|
|
for (my $i = 0; $i < @{$self->{state_data}->{players}}; $i++) {
|
|
if ($self->{state_data}->{players}->[$i]->{id} == $id) {
|
|
if ($self->{state_data}->{players}->[$i]->{removed}) {
|
|
return "$nick: You have been removed from this game. Try again next game.";
|
|
}
|
|
|
|
$self->send_message($channel, "$nick surveys the battlefield!");
|
|
$self->show_battlefield($i);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
# otherwise show spectator board
|
|
$self->show_battlefield($self->{BOARD_SPECTATOR});
|
|
return '';
|
|
}
|
|
|
|
# this command shows the entire battlefield
|
|
when ('fullboard') {
|
|
if (not $self->{pbot}->{users}->loggedin_admin($channel, $hostmask)) {
|
|
return "$nick: Only admins may see the full board.";
|
|
}
|
|
|
|
if (grep { $_ eq $self->{current_state} } qw/nogame challenge genboard gameover/) {
|
|
return "$nick: There is no board to show right now.";
|
|
}
|
|
|
|
# show real board if admin is in the game ... no cheating!
|
|
my $id = $self->get_player_id($nick, $user, $host);
|
|
for (my $i = 0; $i < @{$self->{state_data}->{players}}; $i++) {
|
|
if ($self->{state_data}->{players}->[$i]->{id} == $id) {
|
|
$self->send_message($channel, "$nick surveys the battlefield!");
|
|
$self->show_battlefield($i);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
# show full board
|
|
$self->show_battlefield($self->{BOARD_FULL}, $nick);
|
|
}
|
|
|
|
default { return $usage; }
|
|
}
|
|
}
|
|
|
|
# add a message to PBot output queue, optionally with a delay
|
|
sub send_message {
|
|
my ($self, $to, $text, $delay) = @_;
|
|
|
|
$delay //= 0;
|
|
|
|
my $botnick = $self->{pbot}->{registry}->get_value('irc', 'botnick');
|
|
|
|
my $message = {
|
|
nick => $botnick,
|
|
user => 'battleship',
|
|
host => 'localhost',
|
|
hostmask => "$botnick!battleship\@localhost",
|
|
command => 'battleship',
|
|
checkflood => 1,
|
|
message => $text
|
|
};
|
|
|
|
$self->{pbot}->{interpreter}->add_message_to_output_queue($to, $message, $delay);
|
|
}
|
|
|
|
# get unambiguous internal id for player hostmask
|
|
sub get_player_id {
|
|
my ($self, $nick, $user, $host) = @_;
|
|
my $id = $self->{pbot}->{messagehistory}->{database}->get_message_account($nick, $user, $host);
|
|
return $self->{pbot}->{messagehistory}->{database}->get_ancestor_id($id);
|
|
}
|
|
|
|
# create a new player hash
|
|
sub new_player {
|
|
my ($self, $id, $nick) = @_;
|
|
|
|
return {
|
|
id => $id,
|
|
name => $nick,
|
|
index => 0,
|
|
ready => 0,
|
|
health => 0,
|
|
ships => 0,
|
|
shots => 0,
|
|
hit => 0,
|
|
miss => 0,
|
|
sunk => 0,
|
|
lost => 0,
|
|
missedinputs => 0,
|
|
};
|
|
}
|
|
|
|
# get a random number interval [lower, upper)
|
|
sub number {
|
|
my ($self, $lower, $upper) = @_;
|
|
return int rand($upper - $lower) + $lower;
|
|
}
|
|
|
|
# battleship stuff
|
|
|
|
sub begin_game_loop {
|
|
my ($self) = @_;
|
|
# add `battleship loop` event repeating at 1s interval
|
|
$self->{pbot}->{event_queue}->enqueue_event(
|
|
sub {
|
|
$self->run_one_state;
|
|
},
|
|
1, 'battleship loop', 1
|
|
);
|
|
}
|
|
|
|
sub end_game_loop {
|
|
my ($self) = @_;
|
|
# remove `battleship loop` event
|
|
|
|
# repeating events get added back to event queue if we attempt to
|
|
# dequeue_event() from within the event itself. we turn repeating
|
|
# off to ensure the event gets removed when it completes.
|
|
$self->{pbot}->{event_queue}->update_repeating('battleship loop', 0);
|
|
|
|
# dequeue event.
|
|
$self->{pbot}->{event_queue}->dequeue_event('battleship loop', 0);
|
|
}
|
|
|
|
sub init_game {
|
|
my ($self, $state) = @_;
|
|
|
|
# default board dimensions
|
|
$self->{N_X} = $self->{BOARD_X};
|
|
$self->{N_Y} = $self->{BOARD_Y};
|
|
|
|
# increase board width by player count
|
|
$self->{N_X} += @{$state->{players}} * 2;
|
|
|
|
# default count of ships per player
|
|
$self->{SHIPS} = $self->{SHIP_COUNT};
|
|
|
|
# initialize ship length fields
|
|
for (my $ship = 0; $ship < $self->{SHIPS}; $ship++) {
|
|
$self->{ship_length}->[$ship] = 0;
|
|
}
|
|
|
|
# the battleship board/grid. all ships share the same grid to add an
|
|
# element of strategy (namely, ships cannot overlap thus you know
|
|
# where your enemy ships are NOT located, which narrows the battle
|
|
# field and helps speed games up)
|
|
$self->{board} = [];
|
|
|
|
# reset winner flag
|
|
$self->{got_winner} = 0;
|
|
|
|
# place ships and ocean tiles
|
|
return $self->generate_battlefield;
|
|
}
|
|
|
|
# ensures a ship can be placed at this location (all desired tiles are ocean)
|
|
sub check_ship_placement {
|
|
my ($self, $x, $y, $o, $l) = @_;
|
|
|
|
my ($xd, $yd, $i);
|
|
|
|
if ($o == $self->{ORIENT_VERT}) {
|
|
if ($y + $l >= $self->{N_Y}) {
|
|
return 0;
|
|
}
|
|
$xd = 0;
|
|
$yd = 1;
|
|
} else {
|
|
if ($x + $l >= $self->{N_X}) {
|
|
return 0;
|
|
}
|
|
$xd = 1;
|
|
$yd = 0;
|
|
}
|
|
|
|
for (my $i = 0; $i < $l; $i++) {
|
|
if ($self->{board}->[$x += $o == $self->{ORIENT_HORIZ} ? $xd : 0][$y += $o == $self->{ORIENT_HORIZ} ? 0 : $yd]->{type} != $self->{TYPE_OCEAN}) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
# attempt to place a ship on the battlefield
|
|
sub place_ship {
|
|
my ($self, $player_id, $player_index, $ship) = @_;
|
|
|
|
my ($x, $y, $o, $i, $l);
|
|
my ($yd, $xd) = (0, 0);
|
|
|
|
for (my $attempt = 0; $attempt < 1000; $attempt++) {
|
|
$x = $self->number(0, $self->{N_X});
|
|
$y = $self->number(0, $self->{N_Y});
|
|
|
|
$o = $self->number(1, 10) < 6;
|
|
|
|
if ($self->{ship_length}->[$ship]) {
|
|
# reuse saved length so all players have equal sized ships.
|
|
# perfectly balanced as all things must be.
|
|
$l = $self->{ship_length}->[$ship];
|
|
} else {
|
|
# generate a random length ship
|
|
# TODO: perhaps use a fixed array of guaranteed ship lengths?
|
|
# i think random is more exciting because you never know what
|
|
# kinds of ships are going to be out there.
|
|
$l = $self->number(2, 6);
|
|
}
|
|
|
|
if ($self->{debug}) {
|
|
$self->{pbot}->{logger}->log("attempt to place ship for player $player_index: ship $ship x,y: $x,$y o: $o length: $l\n");
|
|
}
|
|
|
|
if ($self->check_ship_placement($x, $y, $o, $l)) {
|
|
if (!$o) {
|
|
$self->{vert}++;
|
|
|
|
if ($self->{horiz} < $self->{SHIPS} / 2) {
|
|
# generate a battlefield with half vertical and half horizontal ships
|
|
# perfectly balanced as all things must be.
|
|
next;
|
|
}
|
|
|
|
$yd = 1;
|
|
$xd = 0;
|
|
} else {
|
|
$self->{horiz}++;
|
|
|
|
if ($self->{vert} < $self->{SHIPS} / 2) {
|
|
# generate a battlefield with half vertical and half horizontal ships
|
|
# perfectly balanced as all things must be.
|
|
next;
|
|
}
|
|
|
|
$xd = 1;
|
|
$yd = 0;
|
|
}
|
|
|
|
for (my $i = 0; $i < $l; $i++) {
|
|
my $tile_data = {
|
|
type => $self->{TYPE_SHIP},
|
|
player_id => $player_id,
|
|
player_index => $player_index,
|
|
orientation => $o,
|
|
length => $l,
|
|
index => $i,
|
|
hit_by => 0,
|
|
};
|
|
|
|
$self->{board}->[$x += $o == $self->{ORIENT_HORIZ} ? $xd : 0][$y += $o == $self->{ORIENT_HORIZ} ? 0 : $yd] = $tile_data;
|
|
}
|
|
|
|
$self->{ship_length}->[$ship] = $l;
|
|
$self->{state_data}->{players}->[$player_index]->{health} += $l;
|
|
$self->{state_data}->{players}->[$player_index]->{ships} += 1;
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
sub place_whirlpool {
|
|
my ($self) = @_;
|
|
|
|
for (my $attempt = 0; $attempt < 1000; $attempt++) {
|
|
my $x = $self->number(0, $self->{N_X});
|
|
my $y = $self->number(0, $self->{N_Y});
|
|
|
|
# skip non-ocean tiles
|
|
if ($self->{board}->[$x][$y]->{type} != $self->{TYPE_OCEAN}) {
|
|
next;
|
|
}
|
|
|
|
# replace ocean tile with whirlpool
|
|
$self->{board}->[$x][$y]->{type} = $self->{TYPE_WHIRLPOOL};
|
|
$self->{board}->[$x][$y]->{tile} = $self->{TILE_OCEAN}; # whirlpools hidden initially, until shot
|
|
return 1;
|
|
}
|
|
|
|
$self->{pbot}->{logger}->log("Failed to place whirlpool.\n");
|
|
return 0;
|
|
}
|
|
|
|
sub generate_battlefield {
|
|
my ($self) = @_;
|
|
|
|
# fill board with ocean
|
|
for (my $x = 0; $x < $self->{N_X}; $x++) {
|
|
for (my $y = 0; $y < $self->{N_Y}; $y++) {
|
|
$self->{board}->[$x][$y] = {
|
|
type => $self->{TYPE_OCEAN},
|
|
tile => $self->{TILE_OCEAN},
|
|
};
|
|
}
|
|
}
|
|
|
|
# place ships
|
|
for (my $player_index = 0; $player_index < @{$self->{state_data}->{players}}; $player_index++) {
|
|
# counts how many horizontal/vertical ships have been placed so far
|
|
$self->{horiz} = 0;
|
|
$self->{vert} = 0;
|
|
for (my $ship = 0; $ship < $self->{SHIPS}; $ship++) {
|
|
if (!$self->place_ship($self->{state_data}->{players}->[$player_index]->{id}, $player_index, $ship)) {
|
|
return 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
# place whirlpools (2 whirlpools per player)
|
|
for (my $whirlpool = 0; $whirlpool < @{$self->{state_data}->{players}} * 2; $whirlpool++) {
|
|
if (!$self->place_whirlpool) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
# we hit a ship; check if the ship has sunk
|
|
sub check_sunk {
|
|
my ($self, $x, $y) = @_;
|
|
|
|
# alias to the tile we hit
|
|
my $tile = $self->{board}->[$x][$y];
|
|
|
|
if ($tile->{orientation} == $self->{ORIENT_VERT}) {
|
|
my $top = $y - $tile->{index};
|
|
my $bottom = $y + ($tile->{length} - ($tile->{index} + 1));
|
|
|
|
for (my $i = $bottom; $i >= $top; $i--) {
|
|
if (not $self->{board}->[$x][$i]->{hit_by}) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
} else {
|
|
my $left = $x - $tile->{index};
|
|
my $right = $x + ($tile->{length} - ($tile->{index} + 1));
|
|
|
|
for (my $i = $right; $i >= $left; $i--) {
|
|
if (not $self->{board}->[$i][$y]->{hit_by}) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
sub get_attack_text {
|
|
my ($self) = @_;
|
|
|
|
my @attacks = (
|
|
"launches torpedoes at",
|
|
"launches nukes at",
|
|
"fires cannons at",
|
|
"fires torpedoes at",
|
|
"fires nukes at",
|
|
"launches tomahawk missiles at",
|
|
"fires a gatling gun at",
|
|
"launches ballistic missiles at",
|
|
);
|
|
|
|
return $attacks[rand @attacks];
|
|
}
|
|
|
|
# checks if we hit whirlpool, ocean, ship, etc
|
|
# reveals struck whirlpools
|
|
sub check_hit {
|
|
my ($self, $state, $player, $location_data) = @_;
|
|
|
|
my ($x, $y, $location) = (
|
|
$location_data->{x},
|
|
$location_data->{y},
|
|
$location_data->{location},
|
|
);
|
|
|
|
# check if we hit a whirlpool. if so, reveal whirlpool on the
|
|
# battlefield and deflect the shot
|
|
if ($self->{board}->[$x][$y]->{type} == $self->{TYPE_WHIRLPOOL}) {
|
|
# reveal this whirlpool
|
|
$self->{board}->[$x][$y]->{tile} = $self->{TILE_WHIRLPOOL};
|
|
|
|
my $attack = $self->get_attack_text;
|
|
|
|
# keep trying until we don't hit another whirlpool
|
|
while (1) {
|
|
$self->send_message($self->{channel}, "$player->{name} $attack $location! $color{cyan}--- SPLASH! ---$color{reset}");
|
|
|
|
$x = $self->number(0, $self->{N_X});
|
|
$y = $self->number(0, $self->{N_Y});
|
|
|
|
$location = ('A' .. 'Z')[$y] . ($x + 1);
|
|
|
|
$self->send_message($self->{channel}, "$player->{name} hit a whirlpool! It deflects their attack to $location!");
|
|
|
|
if ($self->{board}->[$x][$y]->{type} == $self->{TYPE_WHIRLPOOL}) {
|
|
# hit another whirlpool
|
|
next;
|
|
}
|
|
|
|
# update new location for caller
|
|
$location_data->{x} = $x;
|
|
$location_data->{y} = $y;
|
|
$location_data->{location} = $location;
|
|
|
|
last;
|
|
}
|
|
}
|
|
|
|
# hit a ship, damage self or enemy alike
|
|
if ($self->{board}->[$x][$y]->{type} == $self->{TYPE_SHIP}) {
|
|
my $player_index = $self->{board}->[$x][$y]->{player_index};
|
|
|
|
if ($state->{players}->[$player_index]->{removed}) {
|
|
# removed players no longer exist
|
|
return 0;
|
|
}
|
|
|
|
if ($self->{board}->[$x][$y]->{hit_by}) {
|
|
# this piece has already been struck
|
|
return 0;
|
|
} else {
|
|
# a hit! a very palpable hit.
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
# no hit
|
|
return 0;
|
|
}
|
|
|
|
sub perform_attack {
|
|
my ($self, $state, $player) = @_;
|
|
|
|
$player->{shots}++;
|
|
|
|
# random attack verb
|
|
my $attack = $self->get_attack_text;
|
|
|
|
# attack location
|
|
my $location = delete $player->{location};
|
|
|
|
# convert attack location to board coordinates
|
|
my ($y, $x) = $location =~ /^(.)(.*)/;
|
|
$y = ord($y) - 65;
|
|
$x--;
|
|
|
|
# set location data reference so check_hit can update values
|
|
my $location_data = {
|
|
x => $x,
|
|
y => $y,
|
|
location => $location,
|
|
};
|
|
|
|
# launch a shot and see if it hit a ship (handles hitting whirlpools, ocean, etc)
|
|
my $hit_ship = $self->check_hit($state, $player, $location_data);
|
|
|
|
# location_data can be updated by whirlpools, etc
|
|
$x = $location_data->{x};
|
|
$y = $location_data->{y};
|
|
$location = $location_data->{location};
|
|
|
|
if ($hit_ship) {
|
|
# player hit a ship!
|
|
$self->send_message($self->{channel}, "$player->{name} $attack $location! $color{red}--- HIT! --- $color{reset}");
|
|
|
|
$player->{hit}++;
|
|
|
|
# place hit marker
|
|
$self->{board}->[$x][$y]->{tile} = $color{red} . $self->{TILE_HIT}->[$player->{index}];
|
|
$self->{board}->[$x][$y]->{hit_by} = $player->{id};
|
|
|
|
my $victim = $self->{state_data}->{players}->[$self->{board}->[$x][$y]->{player_index}];
|
|
|
|
# deduct hit points from victim
|
|
$victim->{health} -= 1;
|
|
|
|
# check if ship has sunk (reveal what kind and whose ship it is)
|
|
if ($self->check_sunk($x, $y)) {
|
|
$player->{sunk}++;
|
|
$victim->{ships}--;
|
|
|
|
my $length = $self->{board}->[$x][$y]->{length};
|
|
|
|
my %ship_names = (
|
|
5 => 'battleship',
|
|
4 => 'destroyer',
|
|
3 => 'submarine',
|
|
2 => 'patrol boat',
|
|
);
|
|
|
|
my $ships_left = $victim->{ships};
|
|
my $sections_left = $victim->{health};
|
|
|
|
my $ships = 'ship' . ($ships_left != 1 ? 's' : '');
|
|
my $sections = 'section' . ($sections_left != 1 ? 's' : '');
|
|
|
|
if ($sections_left > 0) {
|
|
$self->send_message($self->{channel}, "$color{red}$player->{name} has sunk $victim->{name}'s $ship_names{$length}! $victim->{name} has $ships_left $ships and $sections_left $sections remaining!$color{reset}");
|
|
} else {
|
|
$self->send_message($self->{channel}, "$color{red}$player->{name} has sunk $victim->{name}'s final $ship_names{$length}! $victim->{name} is out of the game!$color{reset}");
|
|
$victim->{lost} = 1;
|
|
|
|
# check if there is only one player still standing
|
|
my $still_alive = 0;
|
|
my $winner;
|
|
foreach my $p (@{$state->{players}}) {
|
|
next if $p->{removed} or $p->{lost};
|
|
$still_alive++;
|
|
$winner = $p;
|
|
}
|
|
|
|
if ($still_alive == 1) {
|
|
$self->send_message($self->{channel}, "$color{yellow}$winner->{name} has won the game of Battleship!$color{reset}");
|
|
$self->{got_winner} = 1;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
# player missed
|
|
$self->send_message($self->{channel}, "$player->{name} $attack $location! --- miss ---");
|
|
|
|
$player->{miss}++;
|
|
|
|
# place miss marker
|
|
if ($self->{board}->[$x][$y]->{type} == $self->{TYPE_OCEAN}) {
|
|
$self->{board}->[$x][$y]->{tile} = $self->{TILE_MISS};
|
|
$self->{board}->[$x][$y]->{missed_by} = $player->{id};
|
|
}
|
|
}
|
|
}
|
|
|
|
sub list_players {
|
|
my ($self) = @_;
|
|
|
|
my @players;
|
|
|
|
foreach my $player (@{$self->{state_data}->{players}}) {
|
|
push @players, $player->{name} . ($player->{ready} ? '' : " $color{red}(not ready)$color{reset}");
|
|
}
|
|
|
|
if (@players) {
|
|
$self->send_message($self->{channel}, "Current players: " . (join ', ', @players) . ". Use `ready` when you are.");
|
|
}
|
|
}
|
|
|
|
sub show_scoreboard {
|
|
my ($self) = @_;
|
|
|
|
foreach my $player (sort { $b->{health} <=> $a->{health} } @{$self->{state_data}->{players}}) {
|
|
next if $player->{removed};
|
|
|
|
my $buf = sprintf("%-10s shots: %2d, hit: %2d, miss: %2d, acc: %3d%%, sunk: %2d, ships left: %d, sections left: %2d",
|
|
"$player->{name}:",
|
|
$player->{shots},
|
|
$player->{hit},
|
|
$player->{miss},
|
|
int (($player->{hit} / ($player->{shots} ? $player->{shots} : 1)) * 100),
|
|
$player->{sunk},
|
|
$player->{ships},
|
|
$player->{health},
|
|
);
|
|
|
|
$self->send_message($self->{channel}, $buf);
|
|
}
|
|
}
|
|
|
|
sub show_battlefield {
|
|
my ($self, $player_index, $nick) = @_;
|
|
|
|
$self->{pbot}->{logger}->log("Showing battlefield for player $player_index\n");
|
|
|
|
my $player;
|
|
|
|
if ($player_index >= 0) {
|
|
$player = $self->{state_data}->{players}->[$player_index];
|
|
}
|
|
|
|
my $output;
|
|
|
|
# player hit markers, for legend
|
|
my $hits;
|
|
foreach my $p (@{$self->{state_data}->{players}}) {
|
|
$hits .= "$p->{name} hit: $color{red}" . ($p->{index} + 1) . "$color{reset} ";
|
|
}
|
|
|
|
# render legend
|
|
if ($player) {
|
|
$output .= "Legend: Your ships: $self->{TILE_SHIP_VERT} $self->{TILE_SHIP_HORIZ}$color{reset} ${hits}ocean: $self->{TILE_OCEAN}$color{reset} miss: $self->{TILE_MISS}$color{reset} whirlpool: $self->{TILE_WHIRLPOOL}$color{reset}\n";
|
|
}
|
|
elsif ($player_index == $self->{BOARD_FULL} or $player_index == $self->{BOARD_FINAL}) {
|
|
my $ships;
|
|
foreach my $p (@{$self->{state_data}->{players}}) {
|
|
$ships .= "$p->{name}: $self->{TILE_SHIP}->[$p->{index}] ";
|
|
}
|
|
|
|
$output .= "Legend: ${ships}${hits}ocean: $self->{TILE_OCEAN}$color{reset} miss: $self->{TILE_MISS}$color{reset} whirlpool: $self->{TILE_WHIRLPOOL}$color{reset}\n";
|
|
}
|
|
else {
|
|
# spectator
|
|
$output .= "Legend: ${hits}ocean: $self->{TILE_OCEAN}$color{reset} miss: $self->{TILE_MISS}$color{reset} whirlpool: $self->{TILE_WHIRLPOOL}$color{reset}\n";
|
|
}
|
|
|
|
# render top column coordinates
|
|
$output .= "$color{cyan},01 ";
|
|
|
|
for (my $x = 1; $x < $self->{N_X} + 1; $x++) {
|
|
if ($x % 10 == 0) {
|
|
$output .= "$color{yellow},01" if $self->{N_X} > 10;
|
|
$output .= $x % 10;
|
|
$output .= ' ';
|
|
$output .= "$color{cyan},01" if $self->{N_X} > 10;
|
|
} else {
|
|
$output .= $x % 10;
|
|
$output .= ' ';
|
|
}
|
|
}
|
|
|
|
$output .= "\n";
|
|
|
|
# render battlefield row by row
|
|
for (my $y = 0; $y < $self->{N_Y}; $y++) {
|
|
# left row coordinates
|
|
$output .= sprintf("$color{cyan},01%c ", 97 + $y);
|
|
|
|
# render a row of the board column by column
|
|
for (my $x = 0; $x < $self->{N_X}; $x++) {
|
|
my $tile = $self->{board}->[$x][$y];
|
|
|
|
# render ocean/whirlpool, miss, but not hits or ships yet
|
|
if ($tile->{type} != $self->{TYPE_SHIP}) {
|
|
# reveal whirlpools on full/final boards
|
|
if ($player_index == $self->{BOARD_FULL} || $player_index == $self->{BOARD_FINAL}) {
|
|
if ($tile->{type} == $self->{TYPE_WHIRLPOOL}) {
|
|
$output .= $self->{TILE_WHIRLPOOL} . ' ';
|
|
} else {
|
|
# render normal tile (ocean, miss)
|
|
$output .= $tile->{tile} . ' ';
|
|
}
|
|
} else {
|
|
# render normal tile (ocean, revealed/hidden whirlpools, miss)
|
|
$output .= $tile->{tile} . ' ';
|
|
}
|
|
next;
|
|
}
|
|
|
|
# render hits
|
|
if ($tile->{hit_by}) {
|
|
$output .= $tile->{tile} . ' ';
|
|
next;
|
|
}
|
|
|
|
# render ships
|
|
|
|
# render player's view
|
|
if ($player) {
|
|
# not player's ship
|
|
if ($tile->{player_id} != $player->{id}) {
|
|
# ship not found yet, show ocean
|
|
$output .= $self->{TILE_OCEAN} . ' ';
|
|
next;
|
|
}
|
|
|
|
if ($tile->{orientation} == $self->{ORIENT_VERT}) {
|
|
# vertical ship
|
|
$output .= $self->{TILE_SHIP_VERT};
|
|
} else {
|
|
# horizontal ship
|
|
$output .= $self->{TILE_SHIP_HORIZ};
|
|
}
|
|
|
|
$output .= ' ';
|
|
next;
|
|
}
|
|
|
|
# otherwise render spectator, full or final board
|
|
|
|
# spectators are not allowed to see ships unless hit
|
|
if ($player_index == $self->{BOARD_SPECTATOR}) {
|
|
# ship not found yet, show ocean
|
|
$output .= $self->{TILE_OCEAN} . ' ';
|
|
next;
|
|
}
|
|
|
|
# full or final board, show all ships
|
|
$output .= $color{white} . $self->{TILE_SHIP}->[$tile->{player_index}] . ' ';
|
|
}
|
|
|
|
# right row coordinates
|
|
$output .= sprintf("$color{cyan},01%c", 97 + $y);
|
|
$output .= "$color{reset}\n";
|
|
}
|
|
|
|
# bottom column coordinates
|
|
$output .= "$color{cyan},01 ";
|
|
|
|
for (my $x = 1; $x < $self->{N_X} + 1; $x++) {
|
|
if ($x % 10 == 0) {
|
|
$output .= $color{yellow}, 01 if $self->{N_X} > 10;
|
|
$output .= $x % 10;
|
|
$output .= ' ';
|
|
$output .= $color{cyan}, 01 if $self->{N_X} > 10;
|
|
} else {
|
|
$output .= $x % 10;
|
|
$output .= ' ';
|
|
}
|
|
}
|
|
|
|
$output .= "\n";
|
|
|
|
# send output, one message per line
|
|
foreach my $line (split /\n/, $output) {
|
|
if ($player) {
|
|
# player
|
|
$self->send_message($player->{name}, $line);
|
|
}
|
|
elsif ($player_index == $self->{BOARD_FULL}) {
|
|
# full
|
|
$self->send_message($nick, $line);
|
|
}
|
|
else {
|
|
# spectator, final
|
|
$self->send_message($self->{channel}, $line);
|
|
}
|
|
}
|
|
}
|
|
|
|
# game state machine stuff
|
|
|
|
# do one loop of the game engine
|
|
sub run_one_state {
|
|
my ($self) = @_;
|
|
|
|
# don't run a game loop if we're paused
|
|
if ($self->{state_data}->{paused}) {
|
|
return;
|
|
}
|
|
|
|
# check for naughty or missing players
|
|
my $players = 0;
|
|
|
|
foreach my $player (@{$self->{state_data}->{players}}) {
|
|
next if $player->{removed} or $player->{lost};
|
|
|
|
# remove player if they have missed 3 inputs
|
|
if ($player->{missedinputs} >= $self->{MAX_MISSED_MOVES}) {
|
|
$self->send_message(
|
|
$self->{channel},
|
|
"$color{red}$player->{name} has missed too many moves and has been ejected from the game!$color{reset}"
|
|
);
|
|
|
|
$player->{removed} = 1;
|
|
next;
|
|
}
|
|
|
|
# count players still in the game
|
|
$players++;
|
|
}
|
|
|
|
# ensure there are at least 2 players still playing
|
|
if ($self->{current_state} eq 'move' or $self->{current_state} eq 'attack') {
|
|
if ($players < 2 and not $self->{got_winner}) {
|
|
$self->send_message($self->{channel}, "Not enough players left in the game. Aborting...");
|
|
$self->set_state('gameover');
|
|
}
|
|
}
|
|
|
|
# transitioned to a brand new state; prepare first tock
|
|
if ($self->{previous_state} ne $self->{current_state}) {
|
|
$self->{state_data}->{ticks} = 1;
|
|
$self->{state_data}->{first_tock} = 1;
|
|
$self->{state_data}->{tocks} = 0;
|
|
|
|
# dump new state data for logging/debugging
|
|
if ($self->{debug}) {
|
|
$Data::Dumper::Useqq = 1;
|
|
$Data::Dumper::Sortkeys = 1;
|
|
$self->{pbot}->{logger}->log("Battleship: New state: $self->{current_state}\n" . Dumper $self->{state_data});
|
|
}
|
|
}
|
|
|
|
# run one state/tick
|
|
$self->{states}->{$self->{current_state}}->{sub}->($self->{state_data});
|
|
|
|
# transition to next state
|
|
$self->{previous_state} = $self->{current_state};
|
|
$self->{current_state} = $self->{states}->{$self->{current_state}}->{trans}->{$self->{state_data}->{trans}};
|
|
|
|
# reset tick-tock once we've tocked
|
|
if ($self->{state_data}->{tocked}) {
|
|
$self->{state_data}->{tocked} = 0;
|
|
$self->{state_data}->{ticks} = 0;
|
|
$self->{state_data}->{first_tock} = 0;
|
|
}
|
|
|
|
# next tick
|
|
$self->{state_data}->{ticks}++;
|
|
}
|
|
|
|
# skip directly to a state
|
|
sub set_state {
|
|
my ($self, $newstate) = @_;
|
|
$self->{previous_state} = $self->{current_state};
|
|
$self->{current_state} = $newstate;
|
|
$self->{state_data}->{ticks} = 0;
|
|
}
|
|
|
|
# set up game state machine
|
|
sub create_states {
|
|
my ($self) = @_;
|
|
|
|
$self->{pbot}->{logger}->log("Battleship: Creating game state machine\n");
|
|
|
|
# initialize default state
|
|
$self->{previous_state} = '';
|
|
$self->{current_state} = 'nogame';
|
|
|
|
# initialize state data
|
|
$self->{state_data} = {
|
|
players => [], # array of player data
|
|
ticks => 0, # number of ticks elapsed
|
|
paused => 0, # is the game paused?
|
|
};
|
|
|
|
$self->{states} = {
|
|
nogame => {
|
|
sub => sub { $self->state_nogame(@_) },
|
|
trans => {
|
|
challenge => 'challenge',
|
|
nogame => 'nogame',
|
|
}
|
|
},
|
|
challenge => {
|
|
sub => sub { $self->state_challenge(@_) },
|
|
trans => {
|
|
stop => 'nogame',
|
|
wait => 'challenge',
|
|
ready => 'genboard',
|
|
}
|
|
},
|
|
genboard => {
|
|
sub => sub { $self->state_genboard(@_) },
|
|
trans => {
|
|
fail => 'nogame',
|
|
next => 'showboard',
|
|
}
|
|
},
|
|
showboard => {
|
|
sub => sub { $self->state_showboard(@_) },
|
|
trans => {
|
|
next => 'move',
|
|
}
|
|
},
|
|
move => {
|
|
sub => sub { $self->state_move(@_) },
|
|
trans => {
|
|
wait => 'move',
|
|
next => 'attack',
|
|
}
|
|
},
|
|
attack => {
|
|
sub => sub { $self->state_attack(@_) },
|
|
trans => {
|
|
gotwinner => 'gameover',
|
|
next => 'move',
|
|
}
|
|
},
|
|
gameover => {
|
|
sub => sub { $self->state_gameover(@_) },
|
|
trans => {
|
|
next => 'nogame',
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
# game states
|
|
|
|
sub state_nogame {
|
|
my ($self, $state) = @_;
|
|
$self->end_game_loop;
|
|
$state->{trans} = 'nogame';
|
|
}
|
|
|
|
sub state_challenge {
|
|
my ($self, $state) = @_;
|
|
|
|
# max number of times to perform tock action
|
|
$state->{tock_limit} = 5;
|
|
|
|
# tock every 60 ticks
|
|
my $tock = 60;
|
|
|
|
# every tick we check if all players have readied yet
|
|
my $ready = 0;
|
|
|
|
foreach my $player (@{$state->{players}}) {
|
|
$ready++ if $player->{ready};
|
|
}
|
|
|
|
# is it time for a tock?
|
|
if ($state->{ticks} % $tock == 0) {
|
|
$state->{tocked} = 1; # we've tocked
|
|
|
|
# reached maximum number of tocks
|
|
if (++$state->{tocks} > $state->{tock_limit}) {
|
|
$self->send_message($self->{channel}, "Not all players have readied in time. The game has been aborted.");
|
|
$state->{trans} = 'stop';
|
|
$state->{players} = [];
|
|
return;
|
|
}
|
|
|
|
my $max = $self->{MAX_PLAYERS};
|
|
my $avail = $max - @{$self->{state_data}->{players}};
|
|
my $slots = 'slot' . ($avail == 1 ? '' : 's');
|
|
|
|
$self->send_message($self->{channel}, "There is a game of Battleship available! Use `accept` to enter the fray ($avail/$max $slots open).");
|
|
|
|
$self->list_players;
|
|
|
|
if ($ready == 1 && @{$self->{state_data}->{players}} == 1) {
|
|
$self->send_message($self->{channel}, "Cannot begin game with one player.");
|
|
}
|
|
}
|
|
|
|
if ($ready >= 2 && $ready == @{$state->{players}}) {
|
|
# all players ready (min 2 players to start)
|
|
$self->send_message($self->{channel}, "All players ready!");
|
|
$state->{trans} = 'ready';
|
|
} else {
|
|
# wait another tick
|
|
$state->{trans} = 'wait';
|
|
}
|
|
}
|
|
|
|
sub state_genboard {
|
|
my ($self, $state) = @_;
|
|
|
|
if (!$self->init_game($state)) {
|
|
$self->{pbot}->{logger}->log("Failed to generate battlefield\n");
|
|
$self->send_message($self->{channel}, "Failed to generate a suitable battlefield. Please try again.");
|
|
$state->{trans} = 'fail';
|
|
} else {
|
|
$state->{tock_limit} = 3;
|
|
$state->{trans} = 'next';
|
|
}
|
|
}
|
|
|
|
sub state_showboard {
|
|
my ($self, $state) = @_;
|
|
|
|
# pause the game to send the boards to all the players.
|
|
# this is due to output pacing; the messages are trickled out slowly
|
|
# to avoid overflowing the ircd's receive queue. we do not want the
|
|
# game state to advance while the messages are being sent out. the
|
|
# game will resume when the `pbot.output_queue_empty` notification
|
|
# is received.
|
|
$state->{paused} = $self->{PAUSED_FOR_OUTPUT_QUEUE};
|
|
|
|
for (my $player = 0; $player < @{$state->{players}}; $player++) {
|
|
$self->send_message($self->{channel}, "Showing battlefield to $state->{players}->[$player]->{name}...");
|
|
$self->show_battlefield($player);
|
|
}
|
|
|
|
$self->send_message($self->{channel}, "Fight! Anybody (players and spectators) can use `board` at any time to see the battlefield.");
|
|
$state->{trans} = 'next';
|
|
}
|
|
|
|
sub state_move {
|
|
my ($self, $state) = @_;
|
|
|
|
# allow 5 tocks before players have missed their move
|
|
$state->{tock_limit} = 5;
|
|
|
|
# tock every 15 ticks
|
|
my $tock = 15;
|
|
|
|
# tock sooner if this is the first
|
|
if ($state->{first_tock}) {
|
|
$tock = 2;
|
|
}
|
|
|
|
# every tick, check if all players have moved
|
|
my $moved = 0;
|
|
my $players = 0;
|
|
|
|
foreach my $player (@{$state->{players}}) {
|
|
next if $player->{removed} or $player->{lost};
|
|
$moved++ if $player->{location};
|
|
$players++;
|
|
}
|
|
|
|
if ($moved == $players) {
|
|
# all players have moved
|
|
$state->{trans} = 'next';
|
|
return;
|
|
}
|
|
|
|
# tock!
|
|
if ($state->{ticks} % $tock == 0) {
|
|
$state->{tocked} = 1;
|
|
|
|
if (++$state->{tocks} > $state->{tock_limit}) {
|
|
# tock limit reached, flag all players who haven't moved
|
|
my @missed;
|
|
|
|
foreach my $player (@{$state->{players}}) {
|
|
next if $player->{removed} or $player->{lost};
|
|
|
|
if (not $player->{location}) {
|
|
$player->{missedinputs}++;
|
|
push @missed, $player->{name};
|
|
}
|
|
}
|
|
|
|
my $msg = join ', ', @missed;
|
|
|
|
$msg .= " failed to launch an attack in time. They forfeit their turn!";
|
|
|
|
$self->send_message($self->{channel}, $msg);
|
|
|
|
$state->{trans} = 'next';
|
|
return;
|
|
}
|
|
|
|
# notify all players who haven't moved yet
|
|
my @pending;
|
|
|
|
foreach my $player (@{$state->{players}}) {
|
|
next if $player->{removed} or $player->{lost};
|
|
|
|
if (not $player->{location}) {
|
|
push @pending, $player->{name};
|
|
}
|
|
}
|
|
|
|
my $players = join ', ', @pending;
|
|
|
|
my $warning = $state->{tocks} == $state->{tock_limit} ? $color{red} : '';
|
|
|
|
my $remaining = 15 * $state->{tock_limit};
|
|
$remaining -= 15 * ($state->{tocks} - 1);
|
|
$remaining = "(" . (concise duration $remaining) . " remaining)";
|
|
|
|
$self->send_message($self->{channel}, "$players: $warning$remaining Launch an attack now via `bomb <location>`!$color{reset}");
|
|
}
|
|
|
|
$state->{trans} = 'wait';
|
|
}
|
|
|
|
sub state_attack {
|
|
my ($self, $state) = @_;
|
|
|
|
my $trans = 'next';
|
|
|
|
foreach my $player (@{$state->{players}}) {
|
|
# skip removed players
|
|
next if $player->{removed};
|
|
|
|
# skip players who haven't moved
|
|
next if not $player->{location};
|
|
|
|
# launch attack
|
|
$self->perform_attack($state, $player);
|
|
|
|
# transition to gameover if someone won
|
|
$trans = 'gotwinner' if $self->{got_winner};
|
|
}
|
|
|
|
$state->{trans} = $trans;
|
|
}
|
|
|
|
sub state_gameover {
|
|
my ($self, $state) = @_;
|
|
|
|
if (@{$state->{players}} >= 2) {
|
|
$self->show_battlefield($self->{BOARD_FINAL});
|
|
$self->show_scoreboard;
|
|
$self->send_message($self->{channel}, "Game over!");
|
|
}
|
|
|
|
$state->{players} = [];
|
|
$state->{trans} = 'next';
|
|
}
|
|
|
|
1;
|