Compare commits

...

14 Commits

8 changed files with 1400 additions and 257 deletions

135
src/card.rs Normal file
View File

@@ -0,0 +1,135 @@
pub use super::Game;
use serde::{Serialize, Serializer};
use std::fmt;
macro_rules! draw {
($g:ident, $e:expr) => {
$g.players[$g.active_player].draw($e)
};
}
macro_rules! action {
($g:ident, $e:expr) => {
$g.turn_state.actions += $e
};
}
macro_rules! buy {
($g:ident, $e:expr) => {
$g.turn_state.buys += $e
};
}
macro_rules! coin {
($g:ident, $e:expr) => {
$g.turn_state.coin += $e
};
}
fn serialize_card_type<S>(_: &fn(&mut super::Game), serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str("ActionSer")
}
fn serialize_card_type_0<S>(
_: &fn(&super::Game, usize) -> u32,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str("ActionSer")
}
#[derive(Clone, Serialize)]
pub enum CardType {
#[serde(serialize_with = "serialize_card_type")]
Action(fn(&mut Game)),
Attack,
Curse,
#[serde(serialize_with = "serialize_card_type")]
Reaction(fn(&mut Game)),
Treasure(u32),
#[serde(serialize_with = "serialize_card_type_0")]
Victory(fn(&Game, usize) -> u32),
}
#[derive(Clone)]
pub struct Card {
pub cost: u32,
pub name: &'static str,
pub types: Vec<CardType>,
}
impl Card {
pub fn new(name: &'static str, cost: u32) -> Card {
Card {
name,
cost,
types: vec![],
}
}
pub fn with_type(&self, card_type: CardType) -> Card {
let mut card = self.clone();
card.types.push(card_type);
card
}
pub fn action(&self) -> Option<fn(&mut Game)> {
for t in &self.types {
match t {
CardType::Action(effects) => return Some(*effects),
_ => (),
}
}
None
}
pub fn curse(&self) -> Option<()> {
for t in &self.types {
match t {
CardType::Curse => return Some(()),
_ => (),
}
}
None
}
pub fn treasure(&self) -> Option<u32> {
for t in &self.types {
match t {
CardType::Treasure(coin) => return Some(*coin),
_ => (),
}
}
None
}
pub fn victory(&self) -> Option<fn(&Game, usize) -> u32> {
for t in &self.types {
match t {
CardType::Victory(points) => return Some(*points),
_ => (),
}
}
None
}
}
impl Serialize for Card {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.name)
}
}
impl fmt::Display for Card {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.name)
}
}

View File

@@ -1,9 +1,11 @@
mod cards;
#[macro_use]
mod card;
mod sets;
use async_std::{prelude::*, sync::RwLock};
use cards::*;
use card::*;
use itertools::Itertools;
use rand::{seq::SliceRandom, thread_rng};
use rand::{seq::SliceRandom, thread_rng, Rng};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, sync::Arc};
use tide::{Body, Redirect, Request, Response};
@@ -16,13 +18,7 @@ enum ClientMessage {
Chat { message: String },
CreateGame { name: String },
JoinGame { name: String },
StartGame,
EndTurn,
PlayCard { name: String, index: usize },
GainCard { name: String, index: usize },
DrawCard,
Discard { index: usize },
TrashHand { index: usize },
Command { command: Command },
}
#[derive(Serialize)]
@@ -53,15 +49,22 @@ enum ServerMessage {
id: usize,
},
Notification {
text: String,
event: Event,
},
GameOver {
score: Vec<(usize, u32)>,
},
ResolveRequest {
/// Request type and context
request: ResolveRequest,
/// The card that is the source of the request to display to the player
card: String,
},
}
#[derive(Clone, Serialize)]
struct GameSetup {
supply: Vec<Card>,
deck: Vec<Card>,
}
@@ -146,24 +149,172 @@ enum GameState {
impl Default for GameSetup {
fn default() -> GameSetup {
let set = sets::base::base();
GameSetup {
supply: vec![
set.get("Chapel").unwrap(),
set.get("Workshop").unwrap(),
set.get("Bureaucrat").unwrap(),
set.get("Gardens").unwrap(),
set.get("Throne Room").unwrap(),
set.get("Bandit").unwrap(),
set.get("Festival").unwrap(),
set.get("Sentry").unwrap(),
set.get("Witch").unwrap(),
set.get("Artisan").unwrap(),
],
deck: vec![
copper(),
copper(),
copper(),
copper(),
copper(),
copper(),
copper(),
estate(),
estate(),
estate(),
set.get("Copper").unwrap(),
set.get("Copper").unwrap(),
set.get("Copper").unwrap(),
set.get("Copper").unwrap(),
set.get("Copper").unwrap(),
set.get("Copper").unwrap(),
set.get("Copper").unwrap(),
set.get("Estate").unwrap(),
set.get("Estate").unwrap(),
set.get("Estate").unwrap(),
],
}
}
}
struct Game {
/// Which player's input is requested to resolve the current effect
#[derive(Clone, Serialize, PartialEq)]
enum ResolvingPlayer {
ActivePlayer,
AllNonActivePlayers,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum ResolveReply {
HandCardsChosen { choice: Vec<usize> },
SupplyCardChosen { choice: usize },
}
/// Sent by the server to tell the client what information is requested
/// from the player to resolve the current effect
#[derive(Clone, Serialize)]
#[serde(tag = "type")]
enum ResolveRequest {
ChooseHandCardsToDiscard { filter: CardFilter },
GainCard { filter: CardFilter },
}
enum ResolveResult {
Resolved,
NotResolved,
}
#[derive(Clone)]
enum Effect {
/// Trigger effect if another card is played while effect is active
/// return true to indicate the event is resolved and should be removed
OnCardPlayed(fn(&mut Game, &Card) -> bool),
/// Effect that blocks further processing of the game state until
/// some user input is received that allows to fully resolve the effect
Resolving {
card: String,
player: ResolvingPlayer,
request: ResolveRequest,
effect: ResolvingEffectHandler,
},
}
#[derive(Default)]
struct EffectState {
resolved: bool,
players_responded: Vec<usize>,
}
#[derive(Clone, Serialize)]
//#[serde(tag = "type")]
enum CardFilter {
Any,
And(Vec<CardFilter>),
MaxCost(u32),
Type(CardType),
}
impl CardFilter {
fn match_card(&self, card: &Card) -> bool {
match &self {
CardFilter::Any => true,
CardFilter::And(filter) => filter.iter().all(|f| f.match_card(&card)),
CardFilter::MaxCost(cost) => card.cost <= *cost,
CardFilter::Type(card_type) => match card_type {
CardType::Action(_) => card.action().is_some(),
CardType::Attack => {
unimplemented!()
}
CardType::Curse => {
unimplemented!()
}
CardType::Reaction(_) => {
unimplemented!()
}
CardType::Treasure(_) => card.treasure().is_some(),
CardType::Victory(_) => {
unimplemented!()
}
},
}
}
}
type ResolvingEffectHandler =
fn(&mut Game, &ResolveReply, usize, &ResolveRequest, &mut EffectState) -> ResolveResult;
#[derive(Deserialize)]
#[serde(tag = "type")]
enum Command {
BuyCard {
index: usize,
},
Discard {
index: usize,
},
DrawCard,
EndTurn,
GainCard {
index: usize,
},
PlayCard {
index: usize,
},
ResolveReply {
reply: ResolveReply,
},
StartGame,
TrashHand {
index: usize,
},
/// Replace a kingdom card in the supply by another one,
/// only viable during Setup state
ChangeSupply {
index: usize,
name: String,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
enum Event {
CardBought { player: usize, name: &'static str },
CardDrawn { player: usize },
CardGained { player: usize, index: usize },
CardDiscarded { player: usize },
CardTrashed { player: usize, name: &'static str },
CardPlayed { player: usize, name: &'static str },
CardRevealed { player: usize, name: &'static str },
TurnStarted { player: usize },
}
pub struct Game {
effects: Vec<Effect>,
players: Vec<Player>,
state: GameState,
setup: GameSetup,
@@ -172,11 +323,23 @@ struct Game {
supply: Vec<(Card, usize)>,
trash: Vec<Card>,
turn_state: TurnState,
debug_mode: bool,
/// Any effect from a card that requires further input from players
/// and blocks the game until fully resolved.
resolving_effect: Option<(
String,
ResolveRequest,
ResolvingEffectHandler,
ResolvingPlayer,
EffectState,
)>,
}
impl Game {
fn new(player: Player) -> Self {
Self {
effects: vec![],
players: vec![player],
state: GameState::Setup,
setup: GameSetup::default(),
@@ -185,14 +348,29 @@ impl Game {
supply: vec![],
trash: vec![],
turn_state: TurnState::default(),
resolving_effect: None,
debug_mode: true,
}
}
fn supply_name_to_index(&self, name: &String) -> Option<usize> {
for i in 0..self.supply.len() {
if self.supply[i].0.name == name {
return Some(i);
}
}
None
}
fn start(&mut self) {
match self.state {
GameState::Setup => {
self.state = GameState::InProgress;
if self.players.len() > 1 {
self.active_player = thread_rng().gen_range(0..self.players.len());
}
for p in self.players.iter_mut() {
(*p).discard_pile = self.setup.deck.clone();
p.draw(5);
@@ -204,101 +382,24 @@ impl Game {
};
self.supply = vec![
(copper(), 60 - self.players.len() * 7),
(silver(), 40),
(gold(), 30),
(estate(), victory_qty),
(duchy(), victory_qty),
(province(), victory_qty),
(
Card {
name: "Curse".into(),
cost: 0,
types: vec![],
},
10,
),
(
Card {
name: "Cellar".into(),
cost: 2,
types: vec![],
},
10,
),
(
Card {
name: "Moat".into(),
cost: 2,
types: vec![],
},
10,
),
(
Card {
name: "Village".into(),
cost: 3,
types: vec![],
},
10,
),
(
Card {
name: "Merchant".into(),
cost: 3,
types: vec![],
},
10,
),
(
Card {
name: "Workshop".into(),
cost: 3,
types: vec![],
},
10,
),
(
Card {
name: "Smithy".into(),
cost: 4,
types: vec![],
},
10,
),
(
Card {
name: "Remodel".into(),
cost: 4,
types: vec![],
},
10,
),
(
Card {
name: "Militia".into(),
cost: 4,
types: vec![],
},
10,
),
(
Card {
name: "Market".into(),
cost: 5,
types: vec![],
},
10,
),
(
Card {
name: "Mine".into(),
cost: 5,
types: vec![],
},
10,
sets::base::base().get("Copper").unwrap(),
60 - self.players.len() * 7,
),
(sets::base::base().get("Silver").unwrap(), 40),
(sets::base::base().get("Gold").unwrap(), 30),
(sets::base::base().get("Estate").unwrap(), victory_qty),
(sets::base::base().get("Duchy").unwrap(), victory_qty),
(sets::base::base().get("Province").unwrap(), victory_qty),
(sets::base::base().get("Curse").unwrap(), 10),
];
self.setup
.supply
.sort_by(|a, b| a.cost.cmp(&b.cost).then(a.name.cmp(&b.name)));
for supply_card in self.setup.supply.iter() {
self.supply.push((supply_card.clone(), 10));
}
}
_ => {} // Ignore if game is not in setup state
@@ -306,6 +407,10 @@ impl Game {
}
fn end_turn(&mut self) {
if let Some(_) = self.resolving_effect {
return;
}
match self.players.get_mut(self.active_player) {
None => {}
Some(p) => {
@@ -320,6 +425,12 @@ impl Game {
self.active_player += 1;
self.active_player %= self.players.len();
self.turn_state = TurnState::default();
self.effects.clear();
self.emit(Event::TurnStarted {
player: self.active_player,
});
}
// Check if the end game condition is reached and finish the game if so,
@@ -342,27 +453,280 @@ impl Game {
}
}
fn is_active_player(&self, player_id: &str) -> bool {
match self.players.get(self.active_player) {
None => false,
Some(p) => p.id == *player_id,
}
fn is_active_player(&self, player: usize) -> bool {
self.active_player == player
}
fn get_active_player(&mut self) -> &mut Player {
return &mut self.players[self.active_player];
}
pub fn trash_hand(&mut self, player_number: usize, index: usize) {
if let Some(_) = self.resolving_effect {
return;
}
self.trash
.push(self.players[player_number].hand.remove(index));
}
pub fn play_card(&mut self, player_number: usize, index: usize) {
let player = self.players.get_mut(player_number).unwrap();
let card = player.hand.remove(index);
pub fn can_play(&self, player_number: usize, index: usize) -> bool {
if let Some(_) = self.resolving_effect {
return false;
}
let card = self.players[player_number].hand.get(index).unwrap();
match card.action() {
Some(_) => {
if self.turn_state.actions > 0 {
return true;
}
}
None => (),
}
match card.treasure() {
Some(_) => {
return true;
}
None => (),
}
false
}
pub fn play_card(&mut self, player_number: usize, index: usize) -> bool {
if !self.can_play(player_number, index) {
return false;
}
let card = self.players[player_number].hand.remove(index);
self.emit(Event::CardPlayed {
player: player_number,
name: card.name,
});
if let Some(coin) = card.treasure() {
self.turn_state.coin += coin;
}
player.played_cards.push(card);
if let Some(effect) = card.action() {
self.turn_state.actions -= 1;
effect(self);
}
let mut effects = self.effects.clone();
self.effects.clear();
effects.retain(|effect| {
match effect {
Effect::OnCardPlayed(effect) => {
if effect(self, &card) {
return false;
}
}
Effect::Resolving { .. } => {}
}
true
});
self.effects.append(&mut effects);
self.players[player_number].played_cards.push(card);
true
}
fn gain_card(&mut self, player: usize, index: &usize, filter: &CardFilter) -> Result<(), ()> {
if let Some((card, count)) = self.supply.get(*index) {
if *count < 1 {
return Err(());
}
if !filter.match_card(&card) {
return Err(());
}
self.supply.get_mut(*index).unwrap().1 = self.supply.get(*index).unwrap().1 - 1;
let card = self.supply[*index].0.clone();
self.players[player].discard_pile.push(card.clone());
self.emit(Event::CardGained {
player,
index: *index,
});
return Ok(());
}
Err(())
}
pub fn buy_card(&mut self, player_number: usize, index: usize) -> bool /*-> Result<(), &'static str>*/
{
if let Some(_) = self.resolving_effect {
return false;
}
if player_number != self.active_player {
return false;
}
if self.turn_state.buys < 1 {
return false;
}
if let Some(card) = self.supply.get(index).as_deref() {
if card.0.cost <= self.turn_state.coin {
let card = card.0.clone();
self.supply.get_mut(index).unwrap().1 = self.supply.get(index).unwrap().1 - 1;
self.turn_state.coin -= card.cost;
self.turn_state.buys -= 1;
self.players[player_number].discard_pile.push(card);
return true;
//return Ok(());
}
}
//Err("Not enough coin");
false
}
fn add_effect(&mut self, effect: Effect) {
match effect {
Effect::OnCardPlayed(_) => {
self.effects.push(effect);
}
Effect::Resolving {
card,
request,
effect,
player,
} => {
if player == ResolvingPlayer::AllNonActivePlayers && self.players.len() == 1 {
return;
}
let state = EffectState::default();
self.resolving_effect = Some((card, request, effect, player, state));
}
}
}
fn handle_command(&mut self, player: usize, command: Command) {
match command {
Command::ChangeSupply { index, name } => {
if let GameState::Setup = self.state {
if let Some(card) = sets::base::base().get(name.as_ref()) {
self.setup.supply[index] = card;
}
}
}
Command::DrawCard => {
if !self.debug_mode {
return;
}
self.players[player].draw(1);
self.emit(Event::CardDrawn { player });
}
Command::BuyCard { index } => {
if self.buy_card(player, index) {
self.emit(Event::CardBought {
player,
name: self.supply[index].0.name,
});
}
}
Command::EndTurn => {
if self.is_active_player(player) {
self.end_turn();
}
}
Command::GainCard { index } => {
if !self.debug_mode {
return;
}
self.gain_card(player, &index, &CardFilter::Any);
}
Command::Discard { index } => {
if !self.debug_mode {
return;
}
//let card_name = self.players[player].hand[index].clone();
self.players[player].discard(index);
self.emit(Event::CardDiscarded { player });
}
Command::StartGame => {
self.start();
}
Command::TrashHand { index } => {
let name = self.players[player].hand[index].name.clone();
self.trash_hand(player, index);
self.emit(Event::CardTrashed { player, name });
}
Command::PlayCard { index } => {
self.play_card(player, index);
}
Command::ResolveReply { reply } => {
if let Some((card, request, effect, resolve_player, mut state)) =
self.resolving_effect.take()
{
match resolve_player {
ResolvingPlayer::ActivePlayer => {
if player == self.active_player {
match effect(self, &reply, self.active_player, &request, &mut state)
{
ResolveResult::Resolved => state.resolved = true,
ResolveResult::NotResolved => (),
}
}
}
ResolvingPlayer::AllNonActivePlayers => {
if player != self.active_player {
match effect(self, &reply, player, &request, &mut state) {
ResolveResult::Resolved => {
state.players_responded.push(player);
}
ResolveResult::NotResolved => (),
}
}
if state.players_responded.len() == self.players.len() - 1 {
state.resolved = true;
}
}
}
match state.resolved {
false => {
self.resolving_effect =
Some((card, request, effect, resolve_player, state));
}
true => {}
}
}
}
}
}
fn emit(&self, event: Event) {
println!("Emitting event: {:?}", event);
async_std::task::block_on(notify_players(self, event));
}
}
@@ -371,14 +735,14 @@ struct State {
games: Arc<RwLock<HashMap<Uuid, Game>>>,
}
async fn notify_players(game: &Game, text: String) {
let sm = ServerMessage::Notification { text };
async fn notify_players(game: &Game, event: Event) {
let sm = ServerMessage::Notification { event };
broadcast(game, &sm).await;
}
async fn broadcast(game: &Game, sm: &ServerMessage) {
for (_, (_, con)) in game.connections.iter() {
con.send_json(&sm).await.unwrap();
con.send_json(&sm).await;
}
}
@@ -394,8 +758,12 @@ async fn broadcast_state(game: &Game) {
name: html_escape::encode_text(&p.name).into(),
draw_pile_count: p.draw_pile.len(),
hand_count: p.hand.len(),
discard_pile: p.discard_pile.last().map(|c| c.name.clone()),
played_cards: p.played_cards.iter().map(|c| c.name.clone()).collect(),
discard_pile: p.discard_pile.last().map(|c| c.name.clone().into()),
played_cards: p
.played_cards
.iter()
.map(|c| c.name.clone().into())
.collect(),
})
.collect(),
active_player: game.active_player,
@@ -413,6 +781,13 @@ async fn broadcast_state(game: &Game) {
broadcast(&game, &sm).await;
if let GameState::Setup = game.state {
let sm = ServerMessage::GameSetup {
setup: game.setup.clone(),
};
broadcast(&game, &sm).await;
}
for p in game.players.iter() {
let sm = ServerMessage::PlayerHand {
hand: p.hand.clone(),
@@ -430,7 +805,9 @@ async fn broadcast_state(game: &Game) {
.map(|(i, p)| {
let score = p.draw_pile.iter().fold(0, |acc, card| {
if let Some(points) = card.victory() {
acc + points
acc + points(&game, i)
} else if let Some(_) = card.curse() {
acc - 1
} else {
acc
}
@@ -442,6 +819,47 @@ async fn broadcast_state(game: &Game) {
};
broadcast(&game, &sm).await;
}
if let Some((card_name, request, _, ref player, _)) = &game.resolving_effect {
match request {
ResolveRequest::ChooseHandCardsToDiscard { .. } => match player {
ResolvingPlayer::ActivePlayer => {
let p = game.players.get(game.active_player).unwrap();
let sm = ServerMessage::ResolveRequest {
card: card_name.clone(),
request: request.clone(),
};
send_msg(&game, &p, &sm).await;
}
ResolvingPlayer::AllNonActivePlayers => {
for (id, player) in game.players.iter().enumerate() {
let sm = ServerMessage::ResolveRequest {
card: card_name.clone(),
request: request.clone(),
};
if id != game.active_player {
send_msg(&game, &player, &sm).await;
}
}
}
},
ResolveRequest::GainCard { .. } => match player {
ResolvingPlayer::ActivePlayer => {
let p = game.players.get(game.active_player).unwrap();
let sm = ServerMessage::ResolveRequest {
card: card_name.clone(),
request: request.clone(),
};
send_msg(&game, &p, &sm).await;
}
_ => unimplemented!("GainCard for non active players?!"),
},
}
}
}
async fn send_msg(game: &Game, player: &Player, sm: &ServerMessage) {
@@ -608,106 +1026,16 @@ async fn main() -> Result<(), std::io::Error> {
broadcast(&game, &sm).await;
}
ClientMessage::StartGame => {
let mut games = req.state().games.write().await;
let game = games.get_mut(&game_id).unwrap();
game.start();
broadcast_state(&game).await;
}
ClientMessage::EndTurn => {
let mut games = req.state().games.write().await;
let game = games.get_mut(&game_id).unwrap();
if game.is_active_player(&player_id) {
game.end_turn();
broadcast_state(&game).await;
}
}
ClientMessage::CreateGame { .. } => {}
ClientMessage::JoinGame { .. } => {}
ClientMessage::PlayCard { name: _, index } => {
ClientMessage::Command { command } => {
let mut games = req.state().games.write().await;
let game = games.get_mut(&game_id).unwrap();
let card_name = game.players[player_number].hand[index].name.clone();
game.play_card(player_number, index);
game.handle_command(player_number, command);
notify_players(
&game,
format!("{} spielt {}", game.players[player_number].name, card_name),
)
.await;
broadcast_state(&game).await;
}
ClientMessage::GainCard { name: _, index } => {
let mut games = req.state().games.write().await;
let game = games.get_mut(&game_id).unwrap();
game.supply.get_mut(index).unwrap().1 =
game.supply.get(index).unwrap().1 - 1;
let card = game.supply[index].0.clone();
game.players[player_number].discard_pile.push(card.clone());
notify_players(
&game,
format!("{} nimmt {}", game.players[player_number].name, card.name),
)
.await;
broadcast_state(&game).await;
}
ClientMessage::DrawCard => {
let mut games = req.state().games.write().await;
let game = games.get_mut(&game_id).unwrap();
game.players[player_number].draw(1);
notify_players(
&game,
format!("{} zieht eine Karte", game.players[player_number].name),
)
.await;
broadcast_state(&game).await;
}
ClientMessage::Discard { index } => {
let mut games = req.state().games.write().await;
let game = games.get_mut(&game_id).unwrap();
let card_name = game.players[player_number].hand[index].clone();
game.players[player_number].discard(index);
notify_players(
&game,
format!("{} legt {} ab", game.players[player_number].name, card_name),
)
.await;
broadcast_state(&game).await;
}
ClientMessage::TrashHand { index } => {
let mut games = req.state().games.write().await;
let game = games.get_mut(&game_id).unwrap();
let card_name = game.players[player_number].hand[index].clone();
game.trash_hand(player_number, index);
notify_players(
&game,
format!(
"{} entsorgt {}",
game.players[player_number].name, card_name
),
)
.await;
broadcast_state(&game).await;
}
}

445
src/sets/base.rs Normal file
View File

@@ -0,0 +1,445 @@
use crate::card::{Card, CardType};
use crate::{
CardFilter, Effect, Event, ResolveReply, ResolveRequest, ResolveResult::*, ResolvingPlayer,
};
use itertools::Itertools;
pub struct CardSet {
name: &'static str,
cards: Vec<Card>,
}
impl CardSet {
pub fn get(&self, name: &str) -> Option<Card> {
for card in self.cards.iter() {
if card.name == name {
return Some(card.clone());
}
}
None
}
}
pub fn base() -> CardSet {
CardSet {
name: "Dominion",
cards: vec![
copper(),
silver(),
gold(),
estate(),
duchy(),
province(),
curse(),
artisan(),
bandit(),
bureaucrat(),
chapel(),
cellar(),
gardens(),
festival(),
market(),
militia(),
mine(),
merchant(),
moat(),
remodel(),
sentry(),
smithy(),
throne_room(),
village(),
witch(),
workshop(),
],
}
}
fn copper() -> Card {
Card::new("Copper", 0).with_type(CardType::Treasure(1))
}
fn silver() -> Card {
Card::new("Silver", 3).with_type(CardType::Treasure(2))
}
fn gold() -> Card {
Card::new("Gold", 6).with_type(CardType::Treasure(3))
}
fn estate() -> Card {
Card::new("Estate", 2).with_type(CardType::Victory(|_, _| 1))
}
fn duchy() -> Card {
Card::new("Duchy", 5).with_type(CardType::Victory(|_, _| 3))
}
fn province() -> Card {
Card::new("Province", 8).with_type(CardType::Victory(|_, _| 6))
}
fn curse() -> Card {
Card::new("Curse", 8).with_type(CardType::Curse)
}
fn artisan() -> Card {
Card::new("Artisan", 6).with_type(CardType::Action(|_game| {}))
}
fn bandit() -> Card {
Card::new("Bandit", 5)
.with_type(CardType::Attack)
.with_type(CardType::Action(|_game| {}))
}
fn bureaucrat() -> Card {
Card::new("Bureaucrat", 4)
.with_type(CardType::Attack)
.with_type(CardType::Action(|game| {
if let Some(index) = game.supply_name_to_index(&"Silver".into()) {
game.gain_card(game.active_player, &index, &CardFilter::Any);
let card = game.players[game.active_player].discard_pile.pop().unwrap();
game.players[game.active_player].draw_pile.push(card);
}
game.add_effect(Effect::Resolving {
card: "Bureaucrat".into(),
request: ResolveRequest::ChooseHandCardsToDiscard {
filter: CardFilter::Type(CardType::Victory(|_, _| 0)), //FIXME!
},
player: ResolvingPlayer::AllNonActivePlayers,
effect: |game, message, player, _request, _state| {
if let ResolveReply::HandCardsChosen { choice } = message {
if choice.len() > 1 {
return NotResolved;
}
if choice.len() == 0 {
if game.players[player].hand.iter().any(|c| c.name == "Moat") {
game.emit(Event::CardRevealed {
player,
name: "Moat",
});
return Resolved;
}
if !game.players[player]
.hand
.iter()
.any(|c| c.victory().is_some())
{
for card in game.players[player].hand.iter() {
game.emit(Event::CardRevealed {
player,
name: card.name,
});
}
return Resolved;
}
}
if let Some(card) = game.players[player].hand.get(choice[0]) {
if card.victory().is_none() {
return NotResolved;
}
}
let card = game.players[player].hand.remove(choice[0]);
game.emit(Event::CardRevealed {
player,
name: card.name,
});
game.players[player].draw_pile.push(card);
return Resolved;
}
NotResolved
},
});
}))
}
fn cellar() -> Card {
Card::new("Cellar", 2).with_type(CardType::Action(|game| {
action!(game, 1);
game.add_effect(Effect::Resolving {
card: "Cellar".into(),
request: ResolveRequest::ChooseHandCardsToDiscard {
filter: CardFilter::Any,
},
player: ResolvingPlayer::ActivePlayer,
effect: |game, message, _player, _request, _state| match message {
ResolveReply::HandCardsChosen { choice } => {
let mut discarded = 0;
for c in choice.iter().sorted_by(|a, b| b.cmp(a)) {
game.get_active_player().discard(*c);
discarded += 1;
}
game.players[game.active_player].draw(discarded);
Resolved
}
_ => NotResolved,
},
});
}))
}
fn chapel() -> Card {
Card::new("Chapel", 2).with_type(CardType::Action(|game| {
game.add_effect(Effect::Resolving {
card: "Chapel".into(),
request: ResolveRequest::ChooseHandCardsToDiscard {
filter: CardFilter::Any,
},
player: ResolvingPlayer::ActivePlayer,
effect: |game, message, _player, _request, _state| {
if let ResolveReply::HandCardsChosen { choice } = message {
if choice.len() > 4 {
return NotResolved;
}
for c in choice.iter().sorted_by(|a, b| b.cmp(a)) {
let card = game.players[game.active_player].hand.remove(*c);
game.trash.push(card);
}
return Resolved;
}
NotResolved
},
})
}))
}
fn festival() -> Card {
Card::new("Festival", 5).with_type(CardType::Action(|game| {
action!(game, 2);
buy!(game, 1);
coin!(game, 2);
}))
}
fn gardens() -> Card {
Card::new("Gardens", 4).with_type(CardType::Victory(|game, player| {
game.players[player].draw_pile.len() as u32 / 10
}))
}
fn moat() -> Card {
Card::new("Moat", 2)
.with_type(CardType::Action(|game| {
draw!(game, 2);
}))
.with_type(CardType::Reaction(|_| {}))
}
fn sentry() -> Card {
Card::new("Sentry", 5).with_type(CardType::Action(|game| {
draw!(game, 1);
action!(game, 1);
}))
}
fn throne_room() -> Card {
Card::new("Throne Room", 4).with_type(CardType::Action(|_game| {}))
}
fn village() -> Card {
Card::new("Village", 3).with_type(CardType::Action(|game| {
draw!(game, 1);
action!(game, 2);
}))
}
fn merchant() -> Card {
Card::new("Merchant", 3).with_type(CardType::Action(|game| {
draw!(game, 1);
action!(game, 1);
game.add_effect(Effect::OnCardPlayed(|game, card| {
if card.name == "Silver" {
coin!(game, 1);
true
} else {
false
}
}));
}))
}
fn witch() -> Card {
Card::new("Witch", 5)
.with_type(CardType::Attack)
.with_type(CardType::Action(|game| {
draw!(game, 2);
}))
}
fn workshop() -> Card {
Card::new("Workshop", 3).with_type(CardType::Action(|game| {
game.add_effect(Effect::Resolving {
card: "Workshop".into(),
request: ResolveRequest::GainCard {
filter: CardFilter::MaxCost(4),
},
player: ResolvingPlayer::ActivePlayer,
effect: |game, message, player, request, _state| {
if let ResolveReply::SupplyCardChosen { choice } = message {
if let ResolveRequest::GainCard { filter } = request {
if let Ok(()) = game.gain_card(player, choice, filter) {
return Resolved;
}
}
}
NotResolved
},
});
}))
}
fn smithy() -> Card {
Card::new("Smithy", 4).with_type(CardType::Action(|game| draw!(game, 3)))
}
fn remodel() -> Card {
Card::new("Remodel", 4).with_type(CardType::Action(|game| {
game.add_effect(Effect::Resolving {
card: "Remodel".into(),
request: ResolveRequest::ChooseHandCardsToDiscard {
filter: CardFilter::Any,
},
player: ResolvingPlayer::ActivePlayer,
effect: |game, message, _player, _request, _state| {
if let ResolveReply::HandCardsChosen { choice } = message {
if choice.len() != 1 {
return NotResolved;
}
let card = game.players[game.active_player].hand.remove(choice[0]);
let cost = card.cost;
game.trash.push(card);
game.add_effect(Effect::Resolving {
card: "Remodel".into(),
request: ResolveRequest::GainCard {
filter: CardFilter::MaxCost(cost + 2),
},
player: ResolvingPlayer::ActivePlayer,
effect: |game, message, player, request, _state| {
if let ResolveReply::SupplyCardChosen { choice } = message {
if let ResolveRequest::GainCard { filter } = request {
if let Ok(()) = game.gain_card(player, choice, filter) {
return Resolved;
}
}
}
NotResolved
},
});
return Resolved;
}
NotResolved
},
});
}))
}
fn militia() -> Card {
Card::new("Militia", 4)
.with_type(CardType::Attack)
.with_type(CardType::Action(|game| {
coin!(game, 2);
game.add_effect(Effect::Resolving {
card: "Militia".into(),
request: ResolveRequest::ChooseHandCardsToDiscard {
filter: CardFilter::Any,
},
player: ResolvingPlayer::AllNonActivePlayers,
effect: |game, message, player, _request, _state| {
if let ResolveReply::HandCardsChosen { choice } = message {
if choice.len() == 0 {
if !game.players[player].hand.iter().any(|c| c.name == "Moat") {
return Resolved;
}
} else if game.players[player].hand.len() > 3
&& choice.len() != game.players[player].hand.len() - 3
{
return NotResolved;
}
for c in choice.iter().sorted_by(|a, b| b.cmp(a)) {
game.players[player].discard(*c);
}
return Resolved;
}
NotResolved
},
});
}))
}
fn market() -> Card {
Card::new("Market", 5).with_type(CardType::Action(|game| {
draw!(game, 1);
action!(game, 1);
buy!(game, 1);
coin!(game, 1);
}))
}
fn mine() -> Card {
Card::new("Mine", 5).with_type(CardType::Action(|game| {
game.add_effect(Effect::Resolving {
card: "Mine".into(),
request: ResolveRequest::ChooseHandCardsToDiscard {
filter: CardFilter::Type(CardType::Treasure(0)),
},
player: ResolvingPlayer::ActivePlayer,
effect: |game, message, _player, _request, state| {
if let ResolveReply::HandCardsChosen { choice } = message {
if choice.len() != 1 {
return NotResolved;
}
if let Some(card) = game.get_active_player().hand.get(choice[0]) {
if let None = card.treasure() {
return NotResolved;
}
}
let card = game.players[game.active_player].hand.remove(choice[0]);
let cost = card.cost;
game.trash.push(card);
state.resolved = true;
game.add_effect(Effect::Resolving {
card: "Mine".into(),
request: ResolveRequest::GainCard {
filter: CardFilter::And(vec![
CardFilter::Type(CardType::Treasure(0)),
CardFilter::MaxCost(cost + 3),
]),
},
player: ResolvingPlayer::ActivePlayer,
effect: |game, message, player, request, _state| {
if let ResolveReply::SupplyCardChosen { choice } = message {
if let ResolveRequest::GainCard { filter } = request {
if let Ok(()) = game.gain_card(player, choice, filter) {
return Resolved;
}
}
}
NotResolved
},
});
}
NotResolved
},
});
}))
}

1
src/sets/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod base;

BIN
static/audio/chipsStack1.ogg (Stored with Git LFS) Normal file

Binary file not shown.

View File

@@ -81,6 +81,15 @@ img.card:hover {
transition: transform 0.1s ease-in;
}
.player-hand img.card.selected {
transform: translate(0px, -40px) scale(1.1) !important;
box-shadow: 0 0 10px lime;
}
.supply-pile.selected {
box-shadow: 0 0 10px lime;
}
.chat-window {
border: 1px solid black;
width: 300px;
@@ -411,13 +420,118 @@ img.card:hover {
width: 120px;
}
.dialog {
position: absolute;
width: 500px;
border: 1px solid dimgray;
top: 50%;
z-index: 200;
background-color: rgba(1.0, 1.0, 1.0, 0.9);
left: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 20px black;
display: grid;
grid-template-columns: auto auto;
color: white;
visibility: hidden;
grid-template-rows: 5fr auto;
transition: top 0.1s ease-in;
}
.dialog.supply {
top: 80%;
}
.dialog.hand {
top: 20%;
}
.dialog img {
grid-row-start: 1;
grid-row-end: 3;
margin: 5px;
width: 180px;
height: 288px;
}
.dialog button {
grid-row-start: 2;
grid-column-start: 2;
margin: 10px auto;
}
</style>
<script src="/static/mithril.js"></script>
</head>
<body>
<div id="dialog" class="dialog">
<img class="card" style="margin: 5px; width:180px; height:288px;" src="/static/images/cards/cellar.jpg">
<p style="margin: 20px;">Choose any number of cards to discard.</p>
<button onclick="dialog_confirm()">Confirm</button>
</div>
<div id="game"></div>
<script>
var set_data = [
["Cellar", "Market", "Merchant", "Militia", "Mine", "Moat", "Remodel", "Smithy", "Village", "Workshop"],
["Artisan", "Bandit", "Bureaucrat", "Chapel", "Festival", "Gardens", "Sentry", "Throne Room", "Witch", "Workshop"],
];
var turnStartSound = new Audio("/static/audio/chipsStack1.ogg");
var resolve_request;
var enable_hand_selection = false;
var enable_supply_selection = false;
var toggle_hand_selection = function(index) {
document.querySelectorAll(".player-hand img.card")[index].classList.toggle("selected");
}
var toggle_supply_selection = function(index) {
document.querySelectorAll(".supply-area .supply-pile").forEach((c) => {
c.classList.remove("selected");
});
document.querySelectorAll(".supply-area .supply-pile")[index].classList.toggle("selected");
}
function send_command(command, data) {
var payload = data || {};
payload.type = command;
var msg = { type: "Command", "command": payload };
webSocket.send(JSON.stringify(msg));
}
function dialog_confirm(ev) {
if (resolve_request.request.type == "GainCard") {
var selected = document.querySelector(".supply-pile.selected");
if (selected) {
selected = parseInt(selected.dataset.index);
}
document.querySelectorAll(".supply-pile.selected").forEach((c) => {
c.classList.remove("selected");
});
var reply = { type: "SupplyCardChosen", choice: selected };
var msg = { type: "ResolveReply", reply: reply };
send_command("ResolveReply", msg);
enable_supply_selection = false;
} else {
var selected = [];
document.querySelectorAll(".player-hand img.card.selected").forEach((c) => {
selected.push(parseInt(c.dataset.index));
c.classList.remove("selected");
});
var reply = { type: "HandCardsChosen", choice: selected };
var msg = { type: "ResolveReply", reply: reply };
send_command("ResolveReply", msg);
enable_hand_selection = false;
}
document.querySelector("#dialog").style.visibility = "hidden";
dialog.classList.remove("hand");
dialog.classList.remove("supply");
}
var mouseenter = function(ev) {
var img = ev.target.src;
document.querySelector("#preview img").src = img;
@@ -430,20 +544,15 @@ img.card:hover {
function handle_dnd(data) {
if (data.source == "Hand" && data.dest == "InPlay") {
var msg = { type: "PlayCard", name: "", index: data.index};
webSocket.send(JSON.stringify(msg));
send_command("PlayCard", { index: data.index });
} else if (data.source == "Hand" && data.dest == "Discard") {
var msg = { type: "Discard", index: data.index};
webSocket.send(JSON.stringify(msg));
send_command("Discard", { index: data.index });
} else if (data.source == "Supply" && data.dest == "Discard") {
var msg = { type: "GainCard", name: data.name, index: parseInt(data.index) };
webSocket.send(JSON.stringify(msg));
send_command("GainCard", { index: parseInt(data.index)});
} else if (data.source == "DrawPile" && data.dest == "Hand") {
var msg = { type: "DrawCard" };
webSocket.send(JSON.stringify(msg));
send_command("DrawCard", null);
} else if (data.source == "Hand" && data.dest == "Trash") {
var msg = { type: "TrashHand", index: data.index };
webSocket.send(JSON.stringify(msg));
send_command("TrashHand", { index: data.index });
} else {
console.log("handle_dnd: unhandled data", data);
}
@@ -481,7 +590,6 @@ img.card:hover {
function SupplyPile(initialVnode) {
var dragStart = function(ev) {
console.log(ev);
let data = {
source: "Supply",
name: ev.target.dataset.name,
@@ -490,7 +598,18 @@ img.card:hover {
ev.dataTransfer.setData("text", JSON.stringify(data));
}
return {
var click = function(e) {
e.preventDefault();
if (enable_supply_selection) {
toggle_supply_selection(parseInt(e.srcElement.parentElement.dataset.index));
}
}
var doubleclick = function(ev) {
send_command("BuyCard", { index: parseInt(ev.srcElement.parentElement.dataset.index) });
}
return {
view: function(vnode) {
return m(".supply-pile",
{
@@ -499,10 +618,12 @@ img.card:hover {
"data-index": vnode.attrs.index,
ondragstart: dragStart,
draggable: true,
onclick: click,
ondblclick: doubleclick,
},
m("img", {
class: "card",
src: "/static/images/cards/" + vnode.attrs.name.toLowerCase() + ".jpg",
src: card_url(vnode.attrs.name),
draggable: false,
onmouseenter: mouseenter,
onmouseleave: mouseleave,
@@ -573,7 +694,7 @@ img.card:hover {
view: function(vnode) {
var c;
if (vnode.attrs.card) {
c = m("img", {class: "card", src: "/static/images/cards/" + vnode.attrs.card.toLowerCase() + ".jpg"});
c = m("img", {class: "card", src: card_url(vnode.attrs.card)});
}
return m(".discard-pile",
{
@@ -642,6 +763,14 @@ img.card:hover {
handle_dnd(data);
}
var click = function(e) {
e.preventDefault();
console.log("hand card click", e.target.dataset.index);
if (enable_hand_selection) {
toggle_hand_selection(parseInt(e.target.dataset.index));
}
}
return {
view: function(vnode) {
return m(".player-hand",
@@ -652,12 +781,13 @@ img.card:hover {
vnode.attrs.hand.map(function(card, i) {
return m("img", {
class: "card",
src: "/static/images/cards/" + card.toLowerCase() + ".jpg",
src: card_url(card),
draggable: true,
ondragstart: dragStart,
"data-index": i,
onmouseenter: mouseenter,
onmouseleave: mouseleave,
onclick: click,
})
})
)
@@ -669,7 +799,7 @@ img.card:hover {
return {
view: function(vnode) {
return m(".opponent-hand",
m("img", {class: "card", src: "/static/images/cards/Card_back.jpg"}),
m("img", {class: "card", src: "/static/images/cards/Card_back.jpg" }),
m("span", {class: "pile-counter" }, vnode.attrs.count)
)
}
@@ -713,7 +843,7 @@ img.card:hover {
cards.map(function(card) {
return m("img", {
class: "card",
src: "/static/images/cards/" + card.toLowerCase() + ".jpg",
src: card_url(card),
draggable: true,
//ondragstart: dragStart,
onmouseenter: mouseenter,
@@ -727,8 +857,7 @@ img.card:hover {
function PlayerArea(initialVnode) {
var end_turn_click = function(e) {
var msg = { type: "EndTurn" };
webSocket.send(JSON.stringify(msg));
send_command("EndTurn", null);
}
return {
@@ -799,13 +928,24 @@ img.card:hover {
}
}
function card_url(name) {
return "/static/images/cards/" + name.toLowerCase().replace(" ", "-") + ".jpg";
}
function SetupScreen(initialVnode) {
var start_click = function(e) {
let msg = { type: "StartGame" };
let msg = { type: "Command", command: { type: "StartGame" }};
initialVnode.attrs.socket.send(JSON.stringify(msg));
}
var set_changed = function(e) {
let choice = parseInt(e.srcElement.value);
for (card in set_data[choice]) {
let name = set_data[choice][card];
send_command("ChangeSupply", {index: parseInt(card), name: name });
}
}
return {
view: function(vnode) {
@@ -817,18 +957,27 @@ img.card:hover {
vnode.attrs.basic_cards.map(function(card) {
return m("img", {
class: "card",
src: "/static/images/cards/" + card.toLowerCase() + ".jpg",
src: card_url(card),
onmouseenter: mouseenter,
onmouseleave: mouseleave,
})
})
),
m("h4", "Kingdom Cards"),
m("select",
{
name: "set",
onchange: set_changed,
},[
m("option", { value: 0, style: "background-image: url(/static/images/sets/Dominion.png);"}, "First Game"),
m("option", { value: 1}, "Size Distortion"),
]
),
m(".kingdom-cards",
vnode.attrs.kingdom_cards.map(function(card) {
return m("img", {
class: "card",
src: "/static/images/cards/" + card.toLowerCase() + ".jpg",
src: card_url(card),
onmouseenter: mouseenter,
onmouseleave: mouseleave,
})
@@ -839,7 +988,7 @@ img.card:hover {
vnode.attrs.starting_deck.map(function(card) {
return m("img", {
class: "card",
src: "/static/images/cards/" + card.toLowerCase() + ".jpg",
src: card_url(card),
onmouseenter: mouseenter,
onmouseleave: mouseleave,
})
@@ -884,11 +1033,12 @@ img.card:hover {
setup_state = {
starting_deck: [],
basic_cards: ["Copper", "Silver", "Gold", "Estate", "Duchy", "Province", "Curse"],
kingdom_cards: ["Cellar", "Moat", "Village", "Merchant", "Workshop", "Smithy", "Remodel", "Militia", "Market", "Mine"],
kingdom_cards: [],
socket: webSocket
}
var handle_setup = function(data) {
setup_state.kingdom_cards = data.supply;
setup_state.starting_deck = data.deck;
}
@@ -918,6 +1068,7 @@ img.card:hover {
}
var handle_game_state = function(state) {
var last_player = game_state.active_player;
game_state = {
...game_state,
...state,
@@ -933,16 +1084,79 @@ img.card:hover {
}
}
var handle_resolve_request = function(request) {
var dlg = document.querySelector("#dialog");
resolve_request = request;
document.querySelector("#dialog img").src = card_url(request.card);
if (request.request.type == "GainCard") {
let cost = request.request?.filter?.MaxCost;
if (cost) {
document.querySelector("#dialog p").innerHTML = "Choose a card to gain with max cost of " + cost + ".";
} else {
document.querySelector("#dialog p").innerHTML = "Choose a card to gain.";
}
enable_supply_selection = true;
dialog.classList.add("supply");
} else if (request.request.type == "ChooseHandCardsToDiscard") {
document.querySelector("#dialog p").innerHTML = "Choose any number of cards to discard.";
enable_hand_selection = true;
dialog.classList.add("hand");
}
dlg.style.visibility = "visible";
}
var handle_gameover = function(msg) {
gameover_state = msg.score;
var modal = document.querySelector("#modal");
modal.style.display = "block";
m.mount(modal, EndScreen);
}
var handle_notification = function(msg) {
if (msg.event.type == "CardPlayed") {
append_chat(player_name(msg.event.player) + " plays " + msg.event.name);
} else if (msg.event.type == "CardBought") {
append_chat(player_name(msg.event.player) + " buys " + msg.event.name);
} else if (msg.event.type == "CardGained") {
let card_name = game_state.supply[msg.event.index].name;
append_chat(player_name(msg.event.player) + " gains " + card_name);
} else if (msg.event.type == "CardDiscarded") {
append_chat(player_name(msg.event.player) + " discards a card.");
} else if (msg.event.type == "CardTrashed") {
append_chat(player_name(msg.event.player) + " trashes " + msg.event.name);
} else if (msg.event.type == "CardRevealed") {
append_chat(player_name(msg.event.player) + " reveals " + msg.event.name);
} else if (msg.event.type == "TurnStarted") {
if (msg.event.player == my_player_id) {
turnStartSound.play();
}
} else {
console.log(msg);
}
}
var player_name = function(index) {
return game_state.players[index].name;
}
var append_chat = function(text) {
let chatDiv = document.getElementById("chat");
let last_element = document.querySelector("#chat li:last-child");
if (last_element.innerText == text) {
last_element.dataset.repeat = (parseInt(last_element.dataset.repeat || 1)) + 1;
} else {
let newmsg = document.createElement("li");
newmsg.innerHTML = text;
chatDiv.append(newmsg);
newmsg.scrollIntoView();
}
}
webSocket.onopen = function(event) {
console.log("ws open");
//webSocket.send("HALLO");
};
webSocket.onmessage = function(event) {
@@ -955,17 +1169,8 @@ img.card:hover {
chatDiv.append(newmsg);
newmsg.scrollIntoView();
} else if (msg.type == "Notification") {
let chatDiv = document.getElementById("chat");
let last_element = document.querySelector("#chat li:last-child");
if (last_element.innerText == msg.text) {
last_element.dataset.repeat = (parseInt(last_element.dataset.repeat || 1)) + 1;
} else {
let newmsg = document.createElement("li");
newmsg.innerHTML = msg.text;
chatDiv.append(newmsg);
newmsg.scrollIntoView();
}
} else if (msg.type == "PlayerJoined") {
handle_notification(msg);
} else if (msg.type == "PlayerJoined") {
let chatDiv = document.getElementById("chat");
let newmsg = document.createElement("li");
newmsg.innerHTML = msg.player + " joined the game.";
@@ -981,6 +1186,8 @@ img.card:hover {
game_state.player.hand = msg.hand;
} else if (msg.type == "PlayerId") {
my_player_id = msg.id;
} else if (msg.type == "ResolveRequest") {
handle_resolve_request(msg);
} else {
console.log("event?");
console.log(event.data);

BIN
static/images/cards/duchy.jpg (Stored with Git LFS) Normal file

Binary file not shown.

21
static/join.html Normal file
View File

@@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>DnD</title>
</head>
<body>
<p>
<div>
<form id="login_form" method="post">
Your name: <input type="text" name="name"> <br />
<input type="hidden" name="type" value="JoinGame">
<input type="submit" value="Join game">
</form>
</div>
</p>
</body>
</html>