night prompts/actions

This commit is contained in:
emilis 2026-02-20 22:37:31 +00:00
parent d24ed6d86f
commit 80859f58d0
No known key found for this signature in database
53 changed files with 2240 additions and 56 deletions

View File

@ -7,10 +7,20 @@
left: 0;
user-select: none;
font-size: 3em;
.target-picker {
font-size: 1.25em;
.target {
flex-grow: 1;
}
}
.role-reveal {
width: 100%;
height: 100%;
font-size: 2em;
// font-size: 2em;
display: flex;
flex-direction: row;
flex-wrap: wrap;

View File

@ -1,5 +1,9 @@
.icon-fit {
height: 1em;
// height: 1em;
flex-grow: 1;
flex-shrink: 1;
padding: 1ch;
}
.icon {

View File

@ -53,10 +53,13 @@ $starts_as_villager_color_faint: color.change($starts_as_villager_color, $alpha:
$damned_color_faint: color.change($damned_color, $alpha: 0.1);
$drunk_color_faint: color.change($drunk_color, $alpha: 0.1);
$wakes_color: oklch(0.9195 0.1839 109.60356514768961);
@import 'faction';
@import 'setup';
@import 'icon';
@import 'big-screen';
@import 'night';
@mixin flexbox() {
display: -webkit-box;
@ -918,3 +921,17 @@ form {
margin-bottom: 10px;
}
}
.role-title-span {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
text-align: center;
margin: 0 1ch 0 1ch;
padding: 0.25ch 1ch 0.25ch 1ch;
gap: 1ch;
font-size: 1.25em;
}

195
style/night.scss Normal file
View File

@ -0,0 +1,195 @@
.cover-of-darkness {
font-size: 3em;
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
text-wrap: wrap;
p {
padding: 3ch;
}
& button {
width: fit-content;
text-align: center;
align-self: center;
}
}
.wolves-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.information {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
gap: 0.5ch;
font-size: 1.75em;
height: 100%;
.subtext {
font-size: 1.5rem;
}
.arcanist-targets {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1ch;
font-size: 0.7em;
align-items: center;
.and {
font-style: italic;
opacity: 50%;
font-size: 0.7em;
}
}
}
.role-page {
padding: 1vh 1vw 1vh 1vw;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 1ch;
height: 98%;
.title {
font-size: 2em;
font-weight: bold;
display: block;
}
.character {
padding: 1ch;
}
}
.yellow {
color: $wakes_color;
}
.wolves-list {
padding: 1ch;
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: 100%;
align-items: center;
justify-content: space-around;
.character {
display: flex;
flex-direction: column;
align-items: center;
min-width: 37vw;
font-size: 1.5em;
.role {
font-size: 1.25em;
font-weight: bold;
}
}
}
.continue-button {
font-size: 2.25em;
padding: 0.3ch;
margin: 1ch;
}
.breakable {
word-wrap: normal;
}
.inline-icons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5ch;
width: 100%;
align-items: center;
justify-content: center;
}
.target-picker {
display: flex;
flex-direction: row;
flex-wrap: wrap;
height: 100%;
font-size: 2em;
.target {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: color.change($red1, $alpha: 0.1);
border: 1px solid color.change($red1, $alpha: 0.6);
&.marked {
background-color: color.change($blue1, $alpha: 0.3);
border: 1px solid $blue1;
}
}
}
.seer-icons,
.arcanist-icons {
display: flex;
flex-direction: row;
justify-content: space-between;
flex-grow: 1;
flex-shrink: 1;
gap: 10%;
}
.two-column {
display: grid;
grid-template-columns: 3fr 2fr;
height: 100%;
}
.seer-check {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
height: 100%;
align-items: center;
justify-content: space-around;
}
.false-positives {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
font-weight: bold;
font-size: 0.5em;
gap: 10px;
}
.bottom-bound {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
}

View File

@ -68,7 +68,6 @@
}
.wakes {
$wakes_color: oklch(0.9195 0.1839 109.60356514768961);
border: 2px solid $wakes_color;
box-shadow: 0 0 3px $wakes_color;
}

View File

@ -252,7 +252,7 @@ impl ActionPrompt {
| ActionPrompt::Insomniac { .. } => true,
}
}
pub(crate) const fn marked(&self) -> Option<(CharacterId, Option<CharacterId>)> {
pub const fn marked(&self) -> Option<(CharacterId, Option<CharacterId>)> {
match self {
ActionPrompt::Seer { marked, .. }
| ActionPrompt::Protector { marked, .. }
@ -581,6 +581,46 @@ impl ActionPrompt {
_ => false,
}
}
#[rustfmt::skip]
pub fn targets(&self) -> Option<&[CharacterIdentity]> {
match self {
ActionPrompt::Seer { living_players: targets,.. }
| ActionPrompt::Protector { targets,.. }
| ActionPrompt::Arcanist { living_players: targets,.. }
| ActionPrompt::Gravedigger { dead_players: targets,.. }
| ActionPrompt::Hunter { living_players: targets,.. }
| ActionPrompt::Militia { living_players: targets,.. }
| ActionPrompt::MapleWolf { living_players: targets,.. }
| ActionPrompt::Guardian { living_players: targets,.. }
| ActionPrompt::Adjudicator { living_players: targets,.. }
| ActionPrompt::PowerSeer { living_players: targets,.. }
| ActionPrompt::Mortician { dead_players: targets,.. }
| ActionPrompt::BeholderChooses { living_players: targets,.. }
| ActionPrompt::MasonLeaderRecruit { potential_recruits: targets,.. }
| ActionPrompt::Empath { living_players: targets,.. }
| ActionPrompt::Vindicator { living_players: targets,.. }
| ActionPrompt::PyreMaster { living_players: targets,.. }
| ActionPrompt::WolfPackKill { living_villagers: targets,.. }
| ActionPrompt::AlphaWolf { living_villagers: targets,.. }
| ActionPrompt::DireWolf { living_players: targets,.. }
| ActionPrompt::LoneWolfKill { living_players: targets, .. }
| ActionPrompt::Bloodletter {
living_players: targets,
..
} => Some(&**targets),
ActionPrompt::WolvesIntro { .. }
| ActionPrompt::RoleChange { .. }
| ActionPrompt::Shapeshifter { .. }
| ActionPrompt::ElderReveal { .. }
| ActionPrompt::Insomniac { .. }
| ActionPrompt::CoverOfDarkness
| ActionPrompt::MasonsWake { .. }
| ActionPrompt::BeholderWakes { .. }
| ActionPrompt::DamnedIntro { .. } => None,
}
}
}
impl PartialOrd for ActionPrompt {

View File

@ -1,5 +1,13 @@
pub mod pages {
werewolves_macros::include_path!("werewolves/src/app/pages");
pub mod night_actions {
werewolves_macros::include_path!("werewolves/src/app/pages/night_actions");
pub mod role {
werewolves_macros::include_path!("werewolves/src/app/pages/night_actions/role");
}
}
}
pub mod components {

View File

@ -0,0 +1,30 @@
use leptos::prelude::*;
use werewolves_proto::message::{host::HostNightMessage, night::ActionResponse};
#[component]
pub fn Cover(
#[prop(optional)] message: &'static str,
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>,
#[prop(default=HostNightMessage::ActionResponse(ActionResponse::Continue))]
reply_to_send: HostNightMessage,
) -> impl IntoView {
let message = if message.is_empty() {
"go to sleep"
} else {
message
};
let next = move || {
reply.map(|reply| {
let reply_to_send = reply_to_send.clone();
view! { <button on:click=move |_| { reply.set(Some(reply_to_send.clone())) }>"continue"</button> }
})
};
move || {
view! {
<div class="cover-of-darkness">
<p>{message}</p>
{next.clone()}
</div>
}
}
}

View File

@ -2,26 +2,20 @@ use leptos::prelude::*;
use werewolves_proto::message::{Identification, PublicIdentity};
#[component]
pub fn IdentityInline(ident: ReadSignal<PublicIdentity>) -> impl IntoView {
let number = move || {
ident
.read()
pub fn IdentityInline(ident: PublicIdentity) -> impl IntoView {
let number = ident
.number
.as_ref()
.map(|num| view! { <span class="number">{num.get()}</span> }.into_any())
.unwrap_or_else(|| {
view! { <span class="number red">"?"</span> }
.into_any()
})
};
.unwrap_or_else(|| view! { <span class="number red">"?"</span> }.into_any());
let pronouns = move || {
ident.read().pronouns.as_ref().map(|p| {
ident.pronouns.as_ref().map(|p| {
view! { <span class="pronouns">"("{p.clone()}")"</span> }
})
};
view! {
<span class="identity">
{number} <span class="name">{move || ident.read().name.clone()}</span> {pronouns}
{number} <span class="name">{move || ident.name.clone()}</span> {pronouns}
</span>
}
}
@ -29,8 +23,7 @@ pub fn IdentityInline(ident: ReadSignal<PublicIdentity>) -> impl IntoView {
#[component]
pub fn IdentificationInline(ident: Identification) -> impl IntoView {
if !ident.public.name.trim().is_empty() {
return view! { <IdentityInline ident=RwSignal::new(ident.public).read_only() /> }
.into_any();
return view! { <IdentityInline ident=ident.public /> }.into_any();
}
view! {
<span class="identity">

View File

@ -39,7 +39,7 @@ pub fn DialogModal(
DialogMode::Close => view! { <div class="options">{close}</div> }.into_any(),
DialogMode::ConfirmOrClose(on_confirm) => view! {
<div class="options">
<button on:click=move |_| on_confirm.run(())>{"confirm"}</button>
<button on:click=move |_| on_confirm.run(())>confirm</button>
{close}
</div>
}

View File

@ -0,0 +1,50 @@
// Copyright (C) 2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing};
use leptos::prelude::*;
use werewolves_proto::role::RoleTitle;
use crate::app::{
class::{AsClasses, Class},
components::{AssociatedIcon, Icon, IconType, PartialAssociatedIcon},
};
#[component]
pub fn RoleSpan(role: RoleTitle) -> impl IntoView {
let class = role.category().class();
let icon = role
.icon()
.map(|icon| view! { <Icon source=icon r#type=IconType::Small /> }.into_any())
.unwrap_or(view! { <div class="icon-spacer" /> }.into_any());
let role_name = role.to_string().to_case(Case::Title);
view! {
<span class=["role-span", "faint", class]
.as_classes()>{icon} <span class="role-name">{role_name}</span></span>
}
}
#[component]
pub fn RoleTitleSpan(role: RoleTitle) -> impl IntoView {
let class = role.category().class();
let icon = role.icon().unwrap_or(role.alignment().icon());
let text = role.to_string().to_case(Case::Title);
view! {
<span class=["role-title-span", "faint", class, "box"].as_classes()>
<Icon source=icon r#type=IconType::Small />
<span>{text}</span>
</span>
}
}

View File

@ -83,5 +83,5 @@ pub fn Sample(children: Children) -> impl IntoView {
#[component]
pub fn Equals() -> impl IntoView {
view! { <span class="equals">{"="}</span> }
view! { <span class="equals">=</span> }
}

View File

@ -17,4 +17,6 @@ pub enum WolfError {
PasswordConfirmNoMatch,
#[error("please set a seat number")]
NoSeatNumber,
#[error("no targets?")]
NoTargets,
}

View File

@ -5,7 +5,11 @@ use crate::{
ConsoleLogError,
app::{
error::WolfError,
pages::{NotFound, game::host::RoleRevealCharacter},
pages::{
NotFound,
game::host::RoleRevealCharacter,
night_actions::{RolePrompt, RoleResult},
},
storage::user::AuthContextStoreFields,
},
};
@ -19,18 +23,30 @@ use leptos_use::{
use reactive_stores::Store;
use werewolves_proto::{
game::GameId,
message::{IntoClientResponse, WrappedServerMessage, host::ServerToHostMessage},
message::{
CharacterIdentity, IntoClientResponse, WrappedServerMessage,
host::ServerToHostMessage,
night::{ActionPrompt, ActionResult},
},
};
use crate::app::storage::user::AuthContext;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Default)]
enum BigScreenPage {
#[default]
None,
Setup,
RoleReveal,
QrCode,
ActionPrompt {
prompt: ActionPrompt,
page: usize,
},
ActionResult {
character: Option<CharacterIdentity>,
result: ActionResult,
},
}
#[component]
@ -155,8 +171,15 @@ pub fn BigScreen() -> impl IntoView {
settings,
} => todo!(),
ServerToHostMessage::PlayerStates(_) => {}
ServerToHostMessage::ActionPrompt(action_prompt, _) => todo!(),
ServerToHostMessage::ActionResult(character_identity, action_result) => todo!(),
ServerToHostMessage::ActionPrompt(act, ppage) => {
page.set(BigScreenPage::ActionPrompt {
prompt: act,
page: ppage,
});
}
ServerToHostMessage::ActionResult(character, result) => {
page.set(BigScreenPage::ActionResult { character, result })
}
ServerToHostMessage::Lobby {
players: p,
settings: s,
@ -199,9 +222,15 @@ pub fn BigScreen() -> impl IntoView {
BigScreenPage::Setup => {
view! { <SetupView settings=settings.read_only() /> }.into_any()
}
BigScreenPage::RoleReveal => view! { <BigScreenRoleReveal acks=acks.read_only() /> }
.into_any(),
BigScreenPage::RoleReveal => {
view! { <BigScreenRoleReveal acks=acks.read_only() /> }.into_any()
}
BigScreenPage::QrCode => view! { <QrView game_id=game_id /> }.into_any(),
BigScreenPage::ActionPrompt { prompt, page } => {
view! { <RolePrompt prompt=prompt page=page /> }.into_any()
}
BigScreenPage::ActionResult { character, result } => view! { <RoleResult character=character result=result /> }
.into_any(),
};
view! { <div class="big-screen-wrapper">{content}</div> }.into_any()

View File

@ -9,10 +9,9 @@ pub fn BigScreenRoleReveal(acks: ReadSignal<Box<[RoleRevealCharacter]>>) -> impl
.into_iter()
.map(|ackd| {
let RoleRevealCharacter { char, acknowledged } = ackd;
let ident = RwSignal::new(char.into_public()).read_only();
view! {
<div class="player" class:ready=acknowledged>
<IdentityInline ident=ident />
<IdentityInline ident=char.into_public() />
</div>
}
})

View File

@ -17,7 +17,7 @@ pub fn QrView(game_id: GameId) -> impl IntoView {
<div class="qrcode">
<img src=qrcode_url alt="qr code to join" />
<div class="details">
<h3>{"scan the qrcode to join"}</h3>
<h3>scan the qrcode to join</h3>
</div>
</div>
}
@ -74,7 +74,7 @@ pub fn SetupView(settings: ReadSignal<GameSettings>) -> impl IntoView {
{categories} <div class="setup-category big">
<span class="slot">
<span class="count">{power_roles_count}</span>
<span class="title village box">{"Power roles from..."}</span>
<span class="title village box">Power roles from...</span>
</span>
</div>
</div>

View File

@ -6,20 +6,36 @@ use leptos::prelude::*;
use werewolves_proto::{
game::{Category, GameSettings},
message::{
PlayerState,
host::{HostLobbyMessage, HostMessage, ServerToHostMessage as Srv2Host},
CharacterIdentity, PlayerState,
host::{
HostGameMessage, HostLobbyMessage, HostMessage, HostNightMessage,
ServerToHostMessage as Srv2Host,
},
night::{ActionPrompt, ActionResult},
},
};
use crate::app::{Preferences, components::DialogModal};
use crate::app::{
Preferences,
components::DialogModal,
pages::night_actions::{RolePrompt, RoleResult},
};
use crate::{ConsoleLogError, app::error::WolfError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Default)]
enum HostPage {
#[default]
None,
Settings,
RoleRevealAcks,
ActionPrompt {
prompt: ActionPrompt,
page: usize,
},
ActionResult {
character: Option<CharacterIdentity>,
result: ActionResult,
},
}
#[component]
@ -93,6 +109,15 @@ pub fn HostGamePage(
acks.set(reveals);
page.set(HostPage::RoleRevealAcks);
}
Srv2Host::ActionPrompt(prompt, prompt_page) => {
page.set(HostPage::ActionPrompt {
prompt,
page: prompt_page,
});
}
Srv2Host::ActionResult(character, result) => {
page.set(HostPage::ActionResult { character, result })
}
_ => log::error!("{message:#?}"),
}
}
@ -120,6 +145,26 @@ pub fn HostGamePage(
HostPage::RoleRevealAcks => {
view! { <RoleRevealAcks reply=reply error=error acks=acks.read_only() /> }.into_any()
}
HostPage::ActionPrompt { prompt, page } => {
let reply_prompt = RwSignal::new(None);
Effect::new(move || {
if let Some(r) = reply_prompt.get() {
reply.set(Some(HostMessage::InGame(HostGameMessage::Night(r))))
}
});
view! { <RolePrompt reply=reply_prompt.write_only() prompt=prompt page=page /> }
.into_any()
}
HostPage::ActionResult { character, result } => {
let reply_result = RwSignal::new(None);
Effect::new(move || {
if let Some(r) = reply_result.get() {
reply.set(Some(HostMessage::InGame(HostGameMessage::Night(r))))
}
});
view! { <RoleResult reply=reply_result.write_only() character=character result=result /> }
.into_any()
}
};
view! {
{cancel}

View File

@ -23,14 +23,13 @@ pub fn RoleRevealAcks(
.map(|ackd| {
let RoleRevealCharacter { char, acknowledged } = ackd;
let char_id = char.character_id;
let ident = RwSignal::new(char.into_public()).read_only();
let force_ready = move |ev: MouseEvent| {
ev.prevent_default();
reply.set(Some(HostMessage::ForceRoleAckFor(char_id)));
};
view! {
<div class="player" class:ready=acknowledged>
<IdentityInline ident=ident />
<IdentityInline ident=char.into_public() />
<button disabled=acknowledged on:click=force_ready>
"force ready"
</button>

View File

@ -236,15 +236,12 @@ fn SettingsSetupSlot(
.iter()
.find(|p| p.identification.player_id == a)
{
Some(player) => {
let ident = RwSignal::new(player.identification.public.clone());
view! {
Some(player) => view! {
<span class="assignment">
<IdentityInline ident=ident.read_only() />
<IdentityInline ident=player.identification.public.clone() />
</span>
}
.into_any()
}
.into_any(),
None => view! { <span class="missing error">"assigned player not in lobby"</span> }
.into_any(),
}
@ -335,7 +332,7 @@ fn SlotSettingsDialogBody(
.cloned()
})
.map(|p| p.identification.public)
.map(|id| view! { <IdentityInline ident=RwSignal::new(id).read_only() /> }.into_any())
.map(|id| view! { <IdentityInline ident=id /> }.into_any())
.unwrap_or_else(|| view! { "none" }.into_any());
let remove = move |ev: MouseEvent| {
ev.prevent_default();
@ -429,7 +426,6 @@ fn AssignmentSelection(
.as_ref()
.map(|assigned_id| p.identification.player_id == *assigned_id)
.unwrap_or_default();
let ident = RwSignal::new(p.identification.public.clone());
let pid = p.identification.player_id;
let assign = move |ev: MouseEvent| {
ev.prevent_default();
@ -438,7 +434,7 @@ fn AssignmentSelection(
view! {
<button on:click=assign class:selected=assigned class="player-select">
<IdentityInline ident=ident.read_only() />
<IdentityInline ident=p.identification.public.clone() />
</button>
}
})

View File

@ -0,0 +1,29 @@
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn DamnedIntroPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="damned box title">"DAMNED"</span>
<div class="information box damned faint">
<span class="yellow">"YOU ARE DAMNED"</span>
<Icon source=IconSource::Damned />
</div>
</div>
}
}
#[component]
pub fn DamnedIntroPage2() -> impl IntoView {
view! {
<div class="role-page">
<span class="damned box title">"DAMNED"</span>
<div class="information box damned faint">
"YOU RETAIN YOUR ROLE AND WIN IF EVIL WINS"
<span class="yellow">"HOWEVER, YOU CONTRIBUTE TO VILLAGE PARITY"</span>
</div>
</div>
}
}

View File

@ -0,0 +1,18 @@
use leptos::prelude::*;
use werewolves_proto::aura::AuraTitle;
use crate::app::components::{Icon, IconSource, PartialAssociatedIcon};
#[component]
pub fn DrunkPage() -> impl IntoView {
let icon = AuraTitle::Drunk.icon().unwrap_or(IconSource::Roleblock);
view! {
<div class="role-page">
<span class="drunk box title">"DRUNK"</span>
<div class="information box drunk faint">
"YOU GOT DRUNK INSTEAD" <Icon source=icon />
<span class="yellow">"YOUR NIGHT ACTION DID NOT TAKE PLACE"</span>
</div>
</div>
}
}

View File

@ -0,0 +1,327 @@
use leptos::prelude::*;
use werewolves_proto::{
character::CharacterId,
message::{
CharacterIdentity,
host::HostNightMessage,
night::{ActionPrompt, ActionResponse},
},
role::PreviousGuardianAction,
};
use crate::app::{
class::AsClasses,
components::{Cover, IdentityInline},
error::WolfError,
pages::night_actions::{
DamnedIntroPage1, DamnedIntroPage2, RoleChange, WolfPackKill, WolvesIntro,
role::{
AdjudicatorPage1, AlphaWolfPage1, ArcanistPage1, BeholderPage1, BeholderWakePage1,
BloodletterPage1, DirewolfPage1, ElderPage1, ElderPage2, EmpathPage1, GravediggerPage1,
GuardianPageNoPrevProtect, GuardianPagePreviousGuard, GuardianPagePreviousProtect1,
GuardianPagePreviousProtect2, HunterPage1, InsomniacPage1, LoneWolfPage1,
MapleWolfPage1, MasonRecruitPage1, MasonRecruitPage2, MasonsWake, MilitiaPage1,
MorticianPage1, PowerSeerPage1, ProtectorPage1, PyremasterPage1, SeerPage1,
VindicatorPage1,
},
},
};
pub trait RolePage {
fn role_pages(&self, big_screen: bool) -> ViewFn;
}
#[component]
pub fn RolePrompt(
prompt: ActionPrompt,
page: usize,
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>,
#[prop(optional)] error: Option<WriteSignal<Option<WolfError>>>,
) -> impl IntoView {
let ident = move |character_id: CharacterIdentity| {
reply.map(|_| {
view! { <IdentityInline ident=character_id.into() /> }
})
};
let interactive = prompt.interactive();
let targets = prompt.targets().map(|t| t.to_vec().into_boxed_slice());
let marked = prompt
.marked()
.map(|t| [t.0].into_iter().chain(t.1))
.into_iter()
.flatten()
.collect::<Box<_>>();
let mut pages: Vec<AnyView> = match prompt {
ActionPrompt::CoverOfDarkness => vec![match reply {
Some(reply) => view! { <Cover reply=reply /> }.into_any(),
None => view! { <Cover /> }.into_any(),
}],
ActionPrompt::ElderReveal { character_id } => vec![
view! {
{ident(character_id.clone())}
<ElderPage1 />
}
.into_any(),
view! {
{ident(character_id)}
<ElderPage2 />
}
.into_any(),
],
ActionPrompt::Adjudicator { character_id, .. } => vec![
view! {
{ident(character_id)}
<AdjudicatorPage1 />
}
.into_any(),
],
ActionPrompt::RoleChange {
character_id,
new_role,
} => vec![
view! {
{ident(character_id)}
<RoleChange role=new_role />
}
.into_any(),
],
ActionPrompt::Seer { character_id, .. } => vec![
view! {
{ident(character_id)}
<SeerPage1 />
}
.into_any(),
],
ActionPrompt::Protector { character_id, .. } => vec![
view! {
{ident(character_id)}
<ProtectorPage1 />
}
.into_any(),
],
ActionPrompt::Arcanist { character_id, .. } => {
vec![view! { {ident(character_id)} <ArcanistPage1 /> }.into_any()]
}
ActionPrompt::Gravedigger { character_id, .. } => {
vec![view! { {ident(character_id)} <GravediggerPage1 /> }.into_any()]
}
ActionPrompt::Hunter { character_id, .. } => {
vec![view! { {ident(character_id)} <HunterPage1 /> }.into_any()]
}
ActionPrompt::Militia { character_id, .. } => {
vec![view! { {ident(character_id)} <MilitiaPage1 /> }.into_any()]
}
ActionPrompt::MapleWolf {
character_id,
nights_til_starvation,
..
} => {
vec![view! { {ident(character_id)} <MapleWolfPage1 nights_til_starvation=nights_til_starvation/>}.into_any()]
}
ActionPrompt::Guardian {
character_id,
previous: None,
..
} => {
vec![view! { {ident(character_id)} <GuardianPageNoPrevProtect /> }.into_any()]
}
ActionPrompt::Guardian {
character_id,
previous: Some(PreviousGuardianAction::Protect(previous)),
..
} => {
vec![
view! { {ident(character_id.clone())} <GuardianPagePreviousProtect1 previous=previous.clone() /> }
.into_any(),
view! { {ident(character_id)} <GuardianPagePreviousProtect2 previous=previous /> }
.into_any(),
]
}
ActionPrompt::Guardian {
character_id,
previous: Some(PreviousGuardianAction::Guard(previous)),
..
} => {
vec![
view! { {ident(character_id)} <GuardianPagePreviousGuard previous=previous /> }
.into_any(),
]
}
ActionPrompt::PowerSeer { character_id, .. } => {
vec![view! { {ident(character_id)} <PowerSeerPage1 />}.into_any()]
}
ActionPrompt::Mortician { character_id, .. } => {
vec![view! { {ident(character_id)} <MorticianPage1 /> }.into_any()]
}
ActionPrompt::BeholderChooses { character_id, .. } => vec![
view! {
{ident(character_id)}
<BeholderPage1 />
}
.into_any(),
],
ActionPrompt::MasonLeaderRecruit {
character_id,
recruits_left,
..
} => {
vec![
view! { {ident(character_id.clone())} <MasonRecruitPage1 recruits_left=recruits_left /> }
.into_any(),
view! { {ident(character_id)} <MasonRecruitPage2 /> }.into_any(),
]
}
ActionPrompt::Empath { character_id, .. } => {
vec![view! { {ident(character_id)} <EmpathPage1 /> }.into_any()]
}
ActionPrompt::Vindicator { character_id, .. } => {
vec![view! { {ident(character_id)} <VindicatorPage1 /> }.into_any()]
}
ActionPrompt::PyreMaster { character_id, .. } => {
vec![view! { {ident(character_id)} <PyremasterPage1 /> }.into_any()]
}
ActionPrompt::Shapeshifter { .. } => {
vec![]
}
ActionPrompt::AlphaWolf { character_id, .. } => {
vec![view! { {ident(character_id)} <AlphaWolfPage1 /> }.into_any()]
}
ActionPrompt::DireWolf { character_id, .. } => {
vec![view! { {ident(character_id)} <DirewolfPage1 /> }.into_any()]
}
ActionPrompt::LoneWolfKill { character_id, .. } => {
vec![view! { {ident(character_id)} <LoneWolfPage1 /> }.into_any()]
}
ActionPrompt::Insomniac { character_id, .. } => {
vec![view! { {ident(character_id)} <InsomniacPage1 /> }.into_any()]
}
ActionPrompt::Bloodletter { character_id, .. } => {
vec![view! { {ident(character_id)} <BloodletterPage1 /> }.into_any()]
}
ActionPrompt::DamnedIntro { character_id, .. } => vec![
view! {
{ident(character_id.clone())}
<DamnedIntroPage1 />
}
.into_any(),
view! {
{ident(character_id)}
<DamnedIntroPage2 />
}
.into_any(),
],
ActionPrompt::BeholderWakes { character_id, .. } => vec![
view! {
{ident(character_id)}
<BeholderWakePage1 />
}
.into_any(),
],
ActionPrompt::WolfPackKill { .. } => vec![view! { <WolfPackKill /> }.into_any()],
ActionPrompt::MasonsWake { leader, masons } => {
vec![view! { <MasonsWake leader=leader masons=masons /> }.into_any()]
}
ActionPrompt::WolvesIntro { wolves } => {
vec![view! { <WolvesIntro wolves=wolves /> }.into_any()]
}
};
// isn't it great that AnyView isn't Clone????
if let Some(page) = pages.get(page).is_some().then(|| {
let p = pages.swap_remove(page);
let next_btn = reply.map(|reply| {
let next = move |_| {
reply.set(Some(if pages.get(page + 1).is_none() && !interactive {
HostNightMessage::ActionResponse(ActionResponse::ContinueToResult)
} else {
HostNightMessage::NextPage
}));
};
view! {
<button on:click=next class="continue-button">
"continue"
</button>
}
});
view! {
{p}
{next_btn}
}
}) {
return page.into_any();
}
let target_picker = match targets {
Some(targets) => {
let pick = RwSignal::new(None);
match reply {
Some(reply) => {
Effect::new(move || {
if let Some(target) = pick.get() {
reply.set(Some(HostNightMessage::ActionResponse(
ActionResponse::MarkTarget(target),
)));
}
});
view! { <TargetPicker targets=targets marked=marked pick=pick.write_only() /> }
.into_any()
}
None => view! { <TargetPicker targets=targets marked=marked /> }.into_any(),
}
}
None => {
if let Some(error) = error {
error.set(Some(WolfError::NoTargets));
} else {
log::error!("no targets?");
}
().into_any()
}
};
let continue_btn = reply.map(|reply| {
view! {
<button
class="continue-button"
on:click=move |_| {
reply.set(Some(HostNightMessage::ActionResponse(ActionResponse::Continue)))
}
>
"continue"
</button>
}
});
view! {
{target_picker}
{continue_btn}
}
.into_any()
}
#[component]
pub fn TargetPicker(
targets: Box<[CharacterIdentity]>,
#[allow(clippy::boxed_local)] marked: Box<[CharacterId]>,
#[prop(optional)] pick: Option<WriteSignal<Option<CharacterId>>>,
) -> impl IntoView {
let targets = targets
.into_iter()
.map(|target| {
let marked = marked.contains(&target.character_id);
let char_id = target.character_id;
let pick = move |_| {
if let Some(pick) = pick {
pick.set(Some(char_id));
}
};
view! {
<button
class=["character", "target", "no-hover", "village", "faint"].as_classes()
class:marked=marked
on:click=pick
>
<IdentityInline ident=target.into_public() />
</button>
}
})
.collect_view();
view! { <div class="target-picker">{targets}</div> }
}

View File

@ -0,0 +1,80 @@
use leptos::prelude::*;
use werewolves_proto::message::{
CharacterIdentity,
host::HostNightMessage,
night::{ActionResponse, ActionResult},
};
use crate::app::{
components::Cover,
pages::night_actions::{
DrunkPage, RoleblockPage,
role::{
AdjudicatorResult, ArcanistResult, EmpathResult, GravediggerResultPage,
InsomniacResult, MorticianResultPage, PowerSeerResult, SeerResult,
},
},
};
#[component]
pub fn RoleResult(
character: Option<CharacterIdentity>,
result: ActionResult,
#[prop(optional)] reply: Option<WriteSignal<Option<HostNightMessage>>>,
) -> impl IntoView {
let body = match result {
ActionResult::RoleBlocked => view! { <RoleblockPage /> }.into_any(),
ActionResult::Drunk => view! { <DrunkPage /> }.into_any(),
ActionResult::Seer(target, alignment) => view! { <SeerResult target=target.into_public() alignment=alignment /> }.into_any(),
ActionResult::PowerSeer { target, powerful } => view! { <PowerSeerResult target=target.into_public() powerful=powerful /> }.into_any(),
ActionResult::Adjudicator { target, killer } => view! { <AdjudicatorResult target=target.into_public() killer=killer /> }.into_any(),
ActionResult::Arcanist((target1, target2), alignment_eq) => view! {
<ArcanistResult
value=alignment_eq
targets=(target1.into_public(), target2.into_public())
/>
}.into_any(),
ActionResult::GraveDigger(target, role) => view! { <GravediggerResultPage target=target.into_public() role=role /> }.into_any(),
ActionResult::Mortician(target, died_to) => view! { <MorticianResultPage target=target.into_public() died_to=died_to /> }.into_any(),
ActionResult::Insomniac(visits) => view! { <InsomniacResult visits=visits /> }.into_any(),
ActionResult::Empath { target, scapegoat } => view! { <EmpathResult target=target.into_public() scapegoat=scapegoat /> }.into_any(),
ActionResult::BeholderSawNothing => todo!(),
ActionResult::BeholderSawEverything => todo!(),
ActionResult::GoBackToSleep => return match reply {
Some(reply) => view! { <Cover message="go to sleep" reply=reply reply_to_send=HostNightMessage::Next /> }
.into_any(),
None => view! { <Cover message="go to sleep" /> }.into_any(),
},
ActionResult::ShiftFailed => todo!(),
ActionResult::Continue => {
Effect::new(move || {
let Some(reply) = reply else {
return;
};
reply.set(Some(HostNightMessage::ActionResponse(
ActionResponse::ContinueToResult,
)));
});
().into_any()
}
ActionResult::SkippedByHost => todo!(),
};
let next_btn = reply.map(|reply| {
view! {
<button
class="continue-button"
on:click=move |_| {
reply.set(Some(HostNightMessage::ActionResponse(ActionResponse::Continue)))
}
>
"continue"
</button>
}
});
view! {
{body}
{next_btn}
}
.into_any()
}

View File

@ -0,0 +1,54 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::{message::PublicIdentity, role::Killer};
use crate::app::components::{Icon, IconSource, IconType, IdentityInline};
#[component]
pub fn AdjudicatorPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"ADJUDICATOR"</span>
<div class="information box defensive faint">
<h4>"PICK A PLAYER"</h4>
<Icon source=IconSource::Killer r#type=IconType::Fit />
<h4 class="yellow">"YOU WILL CHECK IF THEY APPEAR AS A KILLER"</h4>
</div>
</div>
}
}
#[component]
pub fn AdjudicatorResult(killer: Killer, target: PublicIdentity) -> impl IntoView {
let text = match killer {
Killer::Killer => "IS A KILLER",
Killer::NotKiller => "IS NOT A KILLER",
};
let icon = match killer {
Killer::Killer => view! { <Icon source=IconSource::Killer r#type=IconType::Fit /> },
Killer::NotKiller => view! { <Icon source=IconSource::RedX r#type=IconType::Fit /> },
};
view! {
<div class="role-page">
<span class="defensive box title">"ADJUDICATOR"</span>
<div class="information box defensive faint">
<IdentityInline ident=target.clone() />
{icon}
<h3 class="yellow">{text}</h3>
</div>
</div>
}
}

View File

@ -0,0 +1,31 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
#[component]
pub fn AlphaWolfPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"ALPHA WOLF"</span>
<div class="information box wolves faint">
<span class="breakable">
"IF YOU WISH TO USE YOUR " <span class="yellow">"ONCE PER GAME"</span>
" KILL ABILITY"
</span>
"POINT AT YOUR TARGET OR GO BACK TO SLEEP"
</div>
</div>
}
}

View File

@ -0,0 +1,63 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::{message::PublicIdentity, role::AlignmentEq};
use crate::app::components::{Icon, IconSource, IdentityInline};
#[component]
pub fn ArcanistPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"ARCANIST"</span>
<div class="information box intel faint">
// space
"PICK TWO PLAYERS" <div class="arcanist-icons">
<Icon source=IconSource::Village />
<Icon source=IconSource::Wolves />
</div> <span class="yellow">"YOU WILL COMPARE THEIR ALIGNMENTS"</span>
</div>
</div>
}
}
#[component]
pub fn ArcanistResult(
value: AlignmentEq,
targets: (PublicIdentity, PublicIdentity),
) -> impl IntoView {
let text = match value {
AlignmentEq::Same => "ARE THE SAME",
AlignmentEq::Different => "ARE DIFFERENT",
};
let icons = match value {
AlignmentEq::Same => view! { <Icon source=IconSource::Equal /> },
AlignmentEq::Different => view! { <Icon source=IconSource::NotEqual /> },
};
view! {
<div class="role-page">
<span class="intel box title">"ARCANIST"</span>
<div class="information box intel faint">
<div class="arcanist-targets">
<IdentityInline ident=targets.0 />
<span class="and">"AND"</span>
<IdentityInline ident=targets.1 />
</div>
<div class="icons">{icons}</div>
<span class="yellow">{text}</span>
</div>
</div>
}
}

View File

@ -0,0 +1,75 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn BeholderPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"BEHOLDER"</span>
<div class="information box intel faint">
"PICK A PLAYER" <Icon source=IconSource::Beholder />
"YOU WILL SEE WHAT INFORMATION THEY MAY HAVE GATHERED"
<span class="yellow">"SHOULD THEY DIE TONIGHT"</span>
</div>
</div>
}
}
#[component]
pub fn BeholderWakePage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"BEHOLDER"</span>
<div class="information box intel faint">
"YOUR TARGET HAS DIED" <Icon source=IconSource::Beholder />
<span class="yellow">"THIS IS THE LAST PIECE OF INFORMATION THEY SAW"</span>
</div>
</div>
}
}
#[component]
pub fn BeholderSawNothing() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"BEHOLDER"</span>
<div class="information box intel faint">
<h1>"YOUR TARGET HAS DIED"</h1>
<Icon source=IconSource::RedX />
<h1>"BUT SAW NOTHING"</h1>
</div>
</div>
}
}
#[component]
pub fn BeholderSawEverything() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"BEHOLDER"</span>
<div class="information box intel faint">
"YOUR TARGET HAS DIED" <Icon source=IconSource::Beholder />
<span>
"BUT SAW " <em class="red">
<strong>"EVERYTHING"</strong>
</em>
</span>
</div>
</div>
}
}

View File

@ -0,0 +1,34 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn BloodletterPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"BLOODLETTER"</span>
<div class="information box wolves faint">
<span>"PICK A PLAYER"</span>
<span class="inline-icons">
"THEY'LL APPEAR AS A WOLF " <Icon source=IconSource::Wolves /> "KILLER"
<Icon source=IconSource::Killer /> "AND POWERFUL"
<Icon source=IconSource::Powerful /> "IN CHECKS FOR 2 NIGHTS"
</span>
</div>
</div>
}
}

View File

@ -0,0 +1,30 @@
use leptos::IntoView;
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
#[component]
pub fn DirewolfPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"DIREWOLF"</span>
<div class="information box wolves faint">
"CHOOSE A TARGET"
<span class="yellow">"ANY VISITORS TO THIS TARGET WILL BE ROLE BLOCKED"</span>
"YOU CANNOT CHOOSE YOURSELF OR THE SAME TARGET AS LAST NIGHT"
</div>
</div>
}
}

View File

@ -0,0 +1,43 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
#[component]
pub fn ElderPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="starts-as-villager">"ELDER"</span>
<div class="information box starts-as-villager faint">
"YOU ARE THE ELDER"
<span class="yellow">
"IF YOU ARE EXECUTED BY THE VILLAGE FROM NOW ON " "ALL POWER ROLES WILL BE LOST"
</span>
</div>
</div>
}
}
#[component]
pub fn ElderPage2() -> impl IntoView {
view! {
<div class="role-page">
<span class="starts-as-villager box title">"ELDER"</span>
<div class="information box starts-as-villager faint">
"YOU STARTED THE GAME WITH PROTECTION FROM A NIGHT "
"DEATH — THIS MAY OR MAY NOT STILL BE INTACT"
</div>
</div>
}
}

View File

@ -0,0 +1,55 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::message::PublicIdentity;
use crate::app::components::{Icon, IconSource, IdentityInline};
#[component]
pub fn EmpathPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"EMPATH"</span>
<div class="information box intel faint">
"PICK A PLAYER"
<span class="yellow">"YOU WILL CHECK IF THEY ARE THE SCAPEGOAT"</span>
"IF THEY ARE, YOU WILL TAKE ON THEIR CURSE"
</div>
</div>
}
}
#[component]
pub fn EmpathResult(scapegoat: bool, target: PublicIdentity) -> impl IntoView {
let text = match scapegoat {
true => "IS THE SCAPEGOAT",
false => "IS NOT THE SCAPEGOAT",
};
let icon = match scapegoat {
true => IconSource::Scapegoat,
false => IconSource::RedX,
};
view! {
<div class="role-page">
<span class="intel box title">"EMPATH"</span>
<div class="information box intel faint">
<IdentityInline ident=target />
<Icon source=icon />
<span class="yellow">{text}</span>
</div>
</div>
}
}

View File

@ -0,0 +1,64 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing};
use leptos::prelude::*;
use werewolves_proto::{message::PublicIdentity, role::RoleTitle};
use crate::app::components::{Icon, IconSource, IdentityInline, PartialAssociatedIcon};
#[component]
pub fn GravediggerPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"GRAVEDIGGER"</span>
<div class="information box intel faint">
<span>"PICK A " <span class="yellow">"DEAD"</span> " PLAYER"</span>
<Icon source=IconSource::Gravedigger />
<span class="yellow">"YOU WILL LEARN THEIR ROLE"</span>
</div>
</div>
}
}
#[component]
pub fn GravediggerResultPage(role: Option<RoleTitle>, target: PublicIdentity) -> impl IntoView {
let text = role
.as_ref()
.map(|r| {
view! {
"WAS A "
<span class="yellow">{r.to_string().to_case(Case::Upper)}</span>
}
.into_any()
})
.unwrap_or_else(|| {
view! { "YOU FIND AN EMPTY GRAVE" }
.into_any()
});
let icon = role
.as_ref()
.and_then(|i| i.icon())
.unwrap_or(IconSource::Gravedigger);
view! {
<div class="role-page">
<span class="intel box title">"GRAVEDIGGER"</span>
<div class="information box intel faint">
<IdentityInline ident=target />
<Icon source=icon />
{text}
</div>
</div>
}
}

View File

@ -0,0 +1,93 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::message::CharacterIdentity;
use crate::app::components::{Icon, IconSource, IdentityInline};
#[component]
pub fn GuardianPageNoPrevProtect() -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"GUARDIAN"</span>
<div class="information box defensive faint">
"PICK A PLAYER" <Icon source=IconSource::ShieldAndSword />
<span class="yellow">"CHOOSE SOMEONE TO PROTECT FROM DEATH"</span>
</div>
</div>
}
}
#[component]
pub fn GuardianPagePreviousProtectSelf() -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"GUARDIAN"</span>
<div class="information box defensive faint">
"LAST TIME YOU PROTECTED YOURSELF"
<span class="yellow">"YOU CANNOT PROTECT YOURSELF AGAIN TONIGHT"</span>
</div>
</div>
}
}
#[component]
pub fn GuardianPagePreviousProtect1(previous: CharacterIdentity) -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"GUARDIAN"</span>
<div class="information box defensive faint">
"LAST TIME YOU PROTECTED" <Icon source=IconSource::ShieldAndSword />
<div class="info-player-list">
<IdentityInline ident=previous.into_public() />
</div>
<span class="yellow">"IF YOU PROTECT THEM AGAIN, YOU WILL INSTEAD GUARD THEM"</span>
</div>
</div>
}
}
#[component]
pub fn GuardianPagePreviousProtect2(previous: CharacterIdentity) -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"GUARDIAN"</span>
<div class="information box defensive faint">
"LAST TIME YOU PROTECTED" <Icon source=IconSource::ShieldAndSword />
<div class="info-player-list">
<IdentityInline ident=previous.into_public() />
</div>
<span class="yellow">
"IF ATTACKED WHILE GUARDED, YOU AND THEIR ATTACKER WILL INSTEAD DIE"
</span>
</div>
</div>
}
}
#[component]
pub fn GuardianPagePreviousGuard(previous: CharacterIdentity) -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"GUARDIAN"</span>
<div class="information box defensive faint">
"LAST TIME YOU GUARDED" <Icon source=IconSource::ShieldAndSword />
<div class="info-player-list">
<IdentityInline ident=previous.into_public() />
</div> <span class="yellow">"YOU CANNOT PROTECT THEM TONIGHT"</span>
</div>
</div>
}
}

View File

@ -0,0 +1,33 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn HunterPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="offensive box title">HUNTER</span>
<div class="information box offensive faint">
"SET A HUNTER'S TRAP ON A PLAYER" <Icon source=IconSource::Hunter />
<span class="yellow">
IF YOU DIE TONIGHT, OR ARE EXECUTED TOMORROW
THIS PLAYER WILL DIE AT NIGHT
</span>
</div>
</div>
}
}

View File

@ -0,0 +1,54 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::message::night::Visits;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn InsomniacPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"INSOMNIAC"</span>
<div class="information box intel faint">
"YOUR SLEEP IS INTERRUPTED" <Icon source=IconSource::Insomniac />
"YOU'VE NOTICED VISITORS IN THE NIGHT"
</div>
</div>
}
}
#[component]
pub fn InsomniacResult(visits: Visits) -> impl IntoView {
let visitors = visits
.iter()
.map(|visitor| {
view! {
<div class="identity intel">
<span class="number">{visitor.number.get()}</span>
<span class="name">{visitor.name.clone()}</span>
</div>
}
})
.collect_view();
view! {
<div class="role-page">
<span class="intel box title">"INSOMNIAC"</span>
<div class="information box intel faint">
"YOU WERE VISITED IN THE NIGHT BY:" <div class="info-player-boxes">{visitors}</div>
</div>
</div>
}
}

View File

@ -0,0 +1,31 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
#[component]
pub fn LoneWolfPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">LONE WOLF</span>
<div class="information box wolves faint">
<span>
YOU MUST KILL TONIGHT IN ANGER OVER A FELLOW
WOLF HAVING BEEN SLAIN
</span>
<span class="yellow">PICK A PLAYER</span>
</div>
</div>
}
}

View File

@ -0,0 +1,55 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn MapleWolfPage1(nights_til_starvation: u8) -> impl IntoView {
let food_state = if nights_til_starvation == 0 {
view! {
<span class="red">"YOU ARE STARVING"</span>
"IF YOU FAIL TO EAT TONIGHT, YOU WILL DIE"
}
.into_any()
} else {
let nights = if nights_til_starvation == 1 {
view! { <span class="red">"TOMORROW NIGHT "</span> }
.into_any()
} else {
view! {
"IN "
<span class="red">{nights_til_starvation}</span>
" NIGHTS "
}
.into_any()
};
view! {
<span>
"IF YOU FAIL TO EAT " {nights} "YOU WILL "<span class="yellow">"STARVE"</span>
</span>
}
.into_any()
};
view! {
<div class="role-page">
<span class="offensive box title">"MAPLE WOLF"</span>
<div class="information box offensive faint">
"YOU CAN CHOOSE TO EAT A PLAYER TONIGHT" <Icon source=IconSource::MapleWolf />
{food_state}
</div>
</div>
}
}

View File

@ -0,0 +1,81 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::num::NonZeroU8;
use leptos::prelude::*;
use werewolves_proto::message::CharacterIdentity;
use crate::app::components::IdentityInline;
#[component]
pub fn MasonRecruitPage1(recruits_left: NonZeroU8) -> impl IntoView {
let recruitments = match recruits_left.get() {
0 => unreachable!(),
1 => view! {
<span class="yellow">{1}</span>
" RECRUITMENT"
},
num => view! {
<span class="yellow">{num}</span>
" RECRUITMENTS"
},
};
view! {
<div class="role-page">
<span class="intel box title">"MASON LEADER"</span>
<div class="information box intel faint">
<span>"YOU HAVE "{recruitments}" LEFT"</span>
<span>
"RECRUITS WILL WAKE WITH YOU EVERY NIGHT"
" WHILE THEY ARE ALIVE AND REMAIN VILLAGE ALIGNED"
</span>
</div>
</div>
}
}
#[component]
pub fn MasonRecruitPage2() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"MASON LEADER"</span>
<div class="information box intel faint">
<span class="yellow">"WOULD YOU LIKE TO RECRUIT TONIGHT?"</span>
</div>
</div>
}
}
#[component]
pub fn MasonsWake(leader: CharacterIdentity, masons: Box<[CharacterIdentity]>) -> impl IntoView {
let title = view! {
"MASONS OF "
<span class="yellow">{leader.name.clone()}</span>
};
let masons = masons
.iter()
.map(|mason| {
view! { <IdentityInline ident=mason.clone().into_public() /> }
})
.collect_view();
view! {
<div class="role-page">
<span class="intel box title">{title}</span>
<div class="information box intel faint">
"THE MASONS CONVENE AT NIGHT" <div class="info-player-list masons">{masons}</div>
</div>
</div>
}
}

View File

@ -0,0 +1,34 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn MilitiaPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="offensive box title">"MILITIA"</span>
<div class="information box offensive faint">
<span>
"IF YOU WISH TO USE YOUR " <span class="yellow">"ONCE PER GAME"</span>
" KILL ABILITY"
</span>
<Icon source=IconSource::Sword />
<span class="yellow">"PICK A PLAYER " "OR GO BACK TO SLEEP"</span>
</div>
</div>
}
}

View File

@ -0,0 +1,63 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::{diedto::DiedToTitle, message::PublicIdentity};
use crate::app::components::{Icon, IconSource, IdentityInline, PartialAssociatedIcon};
#[component]
pub fn MorticianPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"MORTICIAN"</span>
<div class="information box intel faint">
<span>"PICK A "<span class="yellow">"DEAD"</span>" PLAYER"</span>
<Icon source=IconSource::Mortician />
<span class="yellow">"YOU WILL LEARN THE CAUSE " "OF THEIR DEATH"</span>
</div>
</div>
}
}
#[component]
pub fn MorticianResultPage(died_to: DiedToTitle, target: PublicIdentity) -> impl IntoView {
let text = match died_to {
DiedToTitle::Execution => "Execution",
DiedToTitle::MapleWolf => "Maple Wolf",
DiedToTitle::MapleWolfStarved => "Starvation",
DiedToTitle::Militia => "Militia Shot",
DiedToTitle::Wolfpack => "Wolfpack",
DiedToTitle::AlphaWolf => "Alpha Wolf",
DiedToTitle::Shapeshift => "Shapeshifting",
DiedToTitle::Hunter => "Hunter Trap",
DiedToTitle::GuardianProtecting => "Guardian",
DiedToTitle::PyreMaster => "Pyre Master",
DiedToTitle::PyreMasterLynchMob => "An Angry Mob of Villagers Against Fire",
DiedToTitle::MasonLeaderRecruitFail => "Occupational Hazard (Mason Recruit Fail)",
DiedToTitle::LoneWolf => "Lone Wolf",
};
let icon = died_to.icon().unwrap_or(IconSource::Mortician);
view! {
<div class="role-page">
<span class="intel box title">"MORTICIAN"</span>
<div class="information box intel faint">
<IdentityInline ident=target />
"DIED TO"
<Icon source=icon />
<span class="yellow">{text}</span>
</div>
</div>
}
}

View File

@ -0,0 +1,54 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::{message::PublicIdentity, role::Powerful};
use crate::app::components::{Icon, IconSource, IdentityInline};
#[component]
pub fn PowerSeerPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"POWER SEER"</span>
<div class="information box intel faint">
"PICK A PLAYER" <Icon source=IconSource::Powerful />
<span class="yellow">"YOU WILL CHECK IF THEY ARE POWERFUL"</span>
</div>
</div>
}
}
#[component]
pub fn PowerSeerResult(powerful: Powerful, target: PublicIdentity) -> impl IntoView {
let text = match powerful {
Powerful::Powerful => "IS POWERFUL",
Powerful::NotPowerful => "IS NOT POWERFUL",
};
let icon = match powerful {
Powerful::Powerful => IconSource::Powerful,
Powerful::NotPowerful => IconSource::RedX,
};
view! {
<div class="role-page">
<span class="intel box title">"POWER SEER"</span>
<div class="information box intel faint">
<IdentityInline ident=target />
<Icon source=icon />
<span class="yellow">{text}</span>
</div>
</div>
}
}

View File

@ -0,0 +1,30 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn ProtectorPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"PROTECTOR"</span>
<div class="information box defensive faint">
"PICK A PLAYER" <Icon source=IconSource::Shield />
<span class="yellow">"YOU WILL PROTECT THEM FROM A DEATH TONIGHT"</span>
</div>
</div>
}
}

View File

@ -0,0 +1,34 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn PyremasterPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="offensive box title">"PYREMASTER"</span>
<div class="information box offensive faint">
"YOU CAN CHOOSE TO THROW A PLAYER ON THE PYRE"
<Icon source=IconSource::Pyremaster />
<span>
"IF YOU KILL " <span class="yellow">"TWO"</span> " GOOD VILLAGERS LIKE THIS "
"YOU WILL DIE AS WELL"
</span>
</div>
</div>
}
}

View File

@ -0,0 +1,92 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use werewolves_proto::{
message::PublicIdentity,
role::{Alignment, RoleTitle},
};
use crate::app::components::{AssociatedIcon, Icon, IconSource, IdentityInline, RoleTitleSpan};
#[component]
pub fn SeerPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="intel box title">"SEER"</span>
<div class="information box intel faint">
"PICK A PLAYER" <div class="seer-icons">
<Icon source=IconSource::Village />
<Icon source=IconSource::Wolves />
</div> <span class="yellow">"YOU WILL CHECK THEIR ALIGNMENT"</span>
</div>
</div>
}
}
#[component]
pub fn SeerResult(alignment: Alignment, target: PublicIdentity) -> impl IntoView {
let text = match alignment {
Alignment::Village => "VILLAGE",
Alignment::Wolves => "WOLFPACK",
Alignment::Damned => "DAMNED",
};
let additional_info = match alignment {
Alignment::Village => view! { <FalselyAppearsAs roles=RoleTitle::falsely_appear_village() alignment_text=text /> }
.into_any(),
Alignment::Wolves => view! { <FalselyAppearsAs roles=RoleTitle::falsely_appear_wolf() alignment_text=text /> }
.into_any(),
Alignment::Damned => view! {
<div class="bottom-bound">
"THIS PERSON IS " <span class="yellow">"DAMNED"</span> "THEY WIN ALONGSIDE EVIL"
</div>
}
.into_any(),
};
view! {
<div class="role-page">
<span class="intel box title">"SEER"</span>
<div class="information box intel faint">
<div class="two-column">
<div class="seer-check">
<IdentityInline ident=target />
<Icon source=alignment.icon() />
<span class="yellow">{text}</span>
</div>
{additional_info}
</div>
</div>
</div>
}
}
#[component]
fn FalselyAppearsAs(
#[allow(clippy::boxed_local)] roles: Box<[RoleTitle]>,
alignment_text: &'static str,
) -> impl IntoView {
let false_positives = roles
.iter()
.copied()
.map(|role| {
view! { <RoleTitleSpan role=role /> }
})
.collect_view();
view! {
<div class="bottom-bound">
"ROLES THAT FALSELY APPEAR AS " <span class="yellow">{alignment_text}</span>
<div class="false-positives yellow">{false_positives}</div>
</div>
}
}

View File

@ -0,0 +1,31 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn VindicatorPage1() -> impl IntoView {
view! {
<div class="role-page">
<span class="defensive box title">"VINDICATOR"</span>
<div class="information box defensive faint">
<span>"A VILLAGER WAS EXECUTED"</span>
<Icon source=IconSource::Vindicator />
<span class="yellow">"PICK A PLAYER TO PROTECT FROM A DEATH TONIGHT"</span>
</div>
</div>
}
}

View File

@ -0,0 +1,43 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing};
use leptos::prelude::*;
use werewolves_proto::role::RoleTitle;
use crate::app::{
class::{AsClasses, Class},
components::{Icon, PartialAssociatedIcon},
};
#[component]
pub fn RoleChange(role: RoleTitle) -> impl IntoView {
let class = role.category().class();
let icon = role.icon().map(|icon| {
view! { <Icon source=icon /> }
});
view! {
<div class="role-page">
<span class=[class, "box", "title"].as_classes()>{"ROLE CHANGE"}</span>
<div class=["information", "box", class, "faint"]
.as_classes()>
{"YOUR ROLE HAS CHANGED"} {icon}
<span>
{"YOUR NEW ROLE IS "}
<span class="yellow">{role.to_string().to_case(Case::Upper)}</span>
</span>
</div>
</div>
}
}

View File

@ -0,0 +1,30 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn RoleblockPage() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"ROLE BLOCKED"</span>
<div class="information box wolves faint">
"YOU WERE ROLE BLOCKED" <Icon source=IconSource::Roleblock />
<span class="yellow">"YOUR NIGHT ACTION DID NOT TAKE PLACE"</span>
</div>
</div>
}
}

View File

@ -0,0 +1,30 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use leptos::prelude::*;
use crate::app::components::{Icon, IconSource};
#[component]
pub fn ShiftFailed() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"SHIFT FAILED"</span>
<div class="information box wolves faint">
"YOUR SHIFT HAS FAILED" <Icon source=IconSource::RedX />
"YOU RETAIN YOUR SHAPESHIFT ABILITY"
</div>
</div>
}
}

View File

@ -0,0 +1,53 @@
// Copyright (C) 2025-2026 Emilis Bliūdžius
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing};
use leptos::prelude::*;
use werewolves_proto::{message::CharacterIdentity, role::RoleTitle};
use crate::app::components::{Icon, IconSource, IdentityInline};
#[component]
pub fn WolvesIntro(wolves: Box<[(CharacterIdentity, RoleTitle)]>) -> impl IntoView {
let wolves = wolves
.into_iter()
.map(|w| {
view! {
<div class="character wolves box faint">
<span class="role yellow">{w.1.to_string().to_case(Case::Title)}</span>
<IdentityInline ident=w.0.into_public() />
</div>
}
})
.collect_view();
view! {
<div class="role-page wolves-intro">
<span class="wolves box title">"THESE ARE THE WOLVES"</span>
<div class="wolves-list">{wolves}</div>
</div>
}
}
#[component]
pub fn WolfPackKill() -> impl IntoView {
view! {
<div class="role-page">
<span class="wolves box title">"WOLF PACK KILL"</span>
<div class="information box wolves faint">
"CHOOSE A TARGET TO EAT TONIGHT" <Icon source=IconSource::Wolves />
<span class="yellow">"WOLVES MUST BE UNANIMOUS"</span>
</div>
</div>
}
}

View File

@ -7,7 +7,7 @@ pub fn NotFound() -> impl IntoView {
provide_meta_context();
view! {
<h2>{"not found"}</h2>
<h2>not found</h2>
<h4>"specifically, this is the 404 page"</h4>
}
}

View File

@ -44,6 +44,10 @@ impl<'a> GameRunner<'a> {
self.game.game_over()
}
pub fn get_game(&self) -> &Game {
&self.game
}
pub async fn process(&mut self, msg: HostOrClientMessage) -> Option<GameOver> {
let start = Utc::now();
let msg = match msg {

View File

@ -94,11 +94,24 @@ impl Host {
}
async fn send_message(&mut self, msg: ServerToHostMessage) -> Result<(), anyhow::Error> {
log::debug!(
match &msg {
ServerToHostMessage::ActionPrompt(prompt, _) => log::debug!(
"sending ActionPrompt({}) message to {}",
prompt.title().to_string().bold(),
self.who.dimmed()
),
ServerToHostMessage::ActionResult(_, result) => log::debug!(
"sending ActionResult({}) message to {}",
result.title().to_string().bold(),
self.who.dimmed()
),
_ => log::debug!(
"sending {} message to {}",
msg.title().to_string().bold(),
self.who.dimmed()
);
),
}
Ok(self
.socket
.send(ws::Message::Binary(

View File

@ -200,6 +200,7 @@ pub async fn run_game(
.await
.is_some()
{
log::info!("game over, man!");
game.game_state = GameRecordState::GameOver(current_game.story());
if let Err(err) = db.game().store_game_state(&game).await {
log::error!("saving game({game_id}) state: {err}; quitting...");
@ -207,6 +208,12 @@ pub async fn run_game(
}
break;
}
game.game_state = GameRecordState::Started(runner.get_game().clone());
log::info!("saving game state");
if let Err(err) = db.game().store_game_state(&game).await {
log::error!("saving game({game_id}) state: {err}; quitting...");
return;
}
}
}
GameRecordState::GameOver(story) => {