story: tab-mode

This commit is contained in:
emilis 2026-01-09 02:03:48 +00:00
parent 7fc90eba74
commit f3f4c43e81
No known key found for this signature in database
16 changed files with 1107 additions and 831 deletions

View File

@ -32,6 +32,7 @@ use crate::{
Alignment, HunterMut, HunterRef, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT, Alignment, HunterMut, HunterRef, Killer, KillingWolfOrder, MAPLE_WOLF_ABSTAIN_LIMIT,
Powerful, PreviousGuardianAction, Role, RoleTitle, Powerful, PreviousGuardianAction, Role, RoleTitle,
}, },
team::Team,
}; };
type Result<T> = core::result::Result<T, GameError>; type Result<T> = core::result::Result<T, GameError>;
@ -242,6 +243,16 @@ impl Character {
!self.is_wolf() !self.is_wolf()
} }
pub fn team(&self) -> Team {
if let Alignment::Traitor = self.alignment() {
return Team::AnyEvil;
}
if self.is_wolf() {
return Team::Wolves;
}
Team::Village
}
pub const fn known_elder(&self) -> bool { pub const fn known_elder(&self) -> bool {
matches!( matches!(
self.role, self.role,
@ -600,8 +611,15 @@ impl Character {
}) })
} }
Role::MasonLeader { .. } => { Role::MasonLeader { .. } => {
log::error!( log::debug!(
"night_action_prompts got to MasonLeader, should be handled before the living check" "night_action_prompts got to MasonLeader;
mason leader alive: {}; night: {night}; current prompt titles: {}",
self.alive(),
prompts
.iter()
.map(|p| p.title().to_string())
.collect::<Vec<_>>()
.join(", ")
); );
} }
Role::Empath { cursed: false } => prompts.push(ActionPrompt::Empath { Role::Empath { cursed: false } => prompts.push(ActionPrompt::Empath {

View File

@ -78,6 +78,30 @@ pub enum DiedTo {
} }
impl DiedTo { impl DiedTo {
pub const fn day(&self) -> Option<NonZeroU8> {
match self {
DiedTo::Execution { day } => Some(*day),
_ => None,
}
}
pub const fn night(&self) -> Option<u8> {
Some(match self {
DiedTo::Execution { .. } => return None,
DiedTo::MapleWolf { night, .. }
| DiedTo::MapleWolfStarved { night }
| DiedTo::Militia { night, .. }
| DiedTo::Wolfpack { night, .. }
| DiedTo::AlphaWolf { night, .. }
| DiedTo::Shapeshift { night, .. }
| DiedTo::Hunter { night, .. }
| DiedTo::GuardianProtecting { night, .. }
| DiedTo::PyreMasterLynchMob { night, .. }
| DiedTo::PyreMaster { night, .. } => night.get(),
DiedTo::LoneWolf { night, .. } | DiedTo::MasonLeaderRecruitFail { night, .. } => *night,
})
}
pub fn next_night(&self) -> Option<DiedTo> { pub fn next_night(&self) -> Option<DiedTo> {
let mut next = self.clone(); let mut next = self.clone();
match &mut next { match &mut next {

View File

@ -192,6 +192,7 @@ impl Game {
GameActions::NightDetails(NightDetails::new( GameActions::NightDetails(NightDetails::new(
&night.used_actions(), &night.used_actions(),
recorded_changes, recorded_changes,
self.village(),
)), )),
)?; )?;
self.state = GameState::Day { self.state = GameState::Day {

View File

@ -45,13 +45,17 @@ pub struct NightDetails {
} }
impl NightDetails { impl NightDetails {
pub fn new(choices: &[(ActionPrompt, ActionResult)], changes: Box<[NightChange]>) -> Self { pub fn new(
choices: &[(ActionPrompt, ActionResult)],
changes: Box<[NightChange]>,
village: &Village,
) -> Self {
Self { Self {
changes, changes,
choices: choices choices: choices
.iter() .iter()
.cloned() .cloned()
.filter_map(|(prompt, result)| NightChoice::new(prompt, result)) .filter_map(|(prompt, result)| NightChoice::new(prompt, result, village))
.collect(), .collect(),
} }
} }
@ -64,9 +68,9 @@ pub struct NightChoice {
} }
impl NightChoice { impl NightChoice {
pub fn new(prompt: ActionPrompt, result: ActionResult) -> Option<Self> { pub fn new(prompt: ActionPrompt, result: ActionResult, village: &Village) -> Option<Self> {
Some(Self { Some(Self {
prompt: StoryActionPrompt::new(prompt)?, prompt: StoryActionPrompt::new(prompt, village)?,
result: StoryActionResult::new(result), result: StoryActionResult::new(result),
}) })
} }
@ -185,6 +189,7 @@ pub enum StoryActionPrompt {
chosen: CharacterId, chosen: CharacterId,
}, },
WolfPackKill { WolfPackKill {
killing_wolf: CharacterId,
chosen: CharacterId, chosen: CharacterId,
}, },
Shapeshifter { Shapeshifter {
@ -215,7 +220,7 @@ pub enum StoryActionPrompt {
} }
impl StoryActionPrompt { impl StoryActionPrompt {
pub fn new(prompt: ActionPrompt) -> Option<Self> { pub fn new(prompt: ActionPrompt, village: &Village) -> Option<Self> {
Some(match prompt { Some(match prompt {
ActionPrompt::BeholderWakes { character_id } => Self::BeholderWakes { ActionPrompt::BeholderWakes { character_id } => Self::BeholderWakes {
character_id: character_id.character_id, character_id: character_id.character_id,
@ -370,7 +375,10 @@ impl StoryActionPrompt {
ActionPrompt::WolfPackKill { ActionPrompt::WolfPackKill {
marked: Some(marked), marked: Some(marked),
.. ..
} => Self::WolfPackKill { chosen: marked }, } => Self::WolfPackKill {
chosen: marked,
killing_wolf: village.killing_wolf().map(|c| c.character_id())?,
},
ActionPrompt::Shapeshifter { character_id } => Self::Shapeshifter { ActionPrompt::Shapeshifter { character_id } => Self::Shapeshifter {
character_id: character_id.character_id, character_id: character_id.character_id,
}, },
@ -433,8 +441,12 @@ impl StoryActionPrompt {
pub const fn character_id(&self) -> Option<CharacterId> { pub const fn character_id(&self) -> Option<CharacterId> {
match self { match self {
StoryActionPrompt::MasonsWake { .. } | StoryActionPrompt::WolfPackKill { .. } => None, StoryActionPrompt::MasonsWake { .. } => None,
StoryActionPrompt::Seer { character_id, .. } StoryActionPrompt::WolfPackKill {
killing_wolf: character_id,
..
}
| StoryActionPrompt::Seer { character_id, .. }
| StoryActionPrompt::Protector { character_id, .. } | StoryActionPrompt::Protector { character_id, .. }
| StoryActionPrompt::Arcanist { character_id, .. } | StoryActionPrompt::Arcanist { character_id, .. }
| StoryActionPrompt::Gravedigger { character_id, .. } | StoryActionPrompt::Gravedigger { character_id, .. }

View File

@ -784,6 +784,34 @@ clients {
display: flex; display: flex;
flex-basis: content; flex-basis: content;
} }
.story {
padding-bottom: 100px;
}
}
@media only screen and (max-width : 799px) {
.story-characters {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 3px;
row-gap: 5px;
justify-content: space-between;
overflow-x: scroll;
padding-bottom: 20px;
}
}
@media only screen and (min-width : 800px) {
.story-characters {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 3px;
row-gap: 5px;
justify-content: space-between;
}
} }
@media only screen and (min-width : 1900px) { @media only screen and (min-width : 1900px) {
@ -1321,7 +1349,7 @@ select {
background-color: $village_color; background-color: $village_color;
border: 1px solid $village_border; border: 1px solid $village_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $village_border; background-color: $village_border;
} }
@ -1330,7 +1358,7 @@ select {
border: 1px solid $village_border_faint; border: 1px solid $village_border_faint;
background-color: $village_color_faint; background-color: $village_color_faint;
&:hover { &.hover:hover {
background-color: $village_border_faint; background-color: $village_border_faint;
} }
} }
@ -1345,7 +1373,7 @@ select {
background-color: $wolves_color; background-color: $wolves_color;
border: 1px solid $wolves_border; border: 1px solid $wolves_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $wolves_border; background-color: $wolves_border;
} }
@ -1354,7 +1382,7 @@ select {
border: 1px solid $wolves_border_faint; border: 1px solid $wolves_border_faint;
background-color: $wolves_color_faint; background-color: $wolves_color_faint;
&:hover { &.hover:hover {
background-color: $wolves_border_faint; background-color: $wolves_border_faint;
} }
} }
@ -1369,7 +1397,7 @@ select {
background-color: $intel_color; background-color: $intel_color;
border: 1px solid $intel_border; border: 1px solid $intel_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $intel_border; background-color: $intel_border;
} }
@ -1378,7 +1406,7 @@ select {
border: 1px solid $intel_border_faint; border: 1px solid $intel_border_faint;
background-color: $intel_color_faint; background-color: $intel_color_faint;
&:hover { &.hover:hover {
background-color: $intel_border_faint; background-color: $intel_border_faint;
} }
} }
@ -1393,7 +1421,7 @@ select {
background-color: $defensive_color; background-color: $defensive_color;
border: 1px solid $defensive_border; border: 1px solid $defensive_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $defensive_border; background-color: $defensive_border;
} }
@ -1402,7 +1430,7 @@ select {
border: 1px solid $defensive_border_faint; border: 1px solid $defensive_border_faint;
background-color: $defensive_color_faint; background-color: $defensive_color_faint;
&:hover { &.hover:hover {
background-color: $defensive_border_faint; background-color: $defensive_border_faint;
} }
} }
@ -1417,7 +1445,7 @@ select {
background-color: $offensive_color; background-color: $offensive_color;
border: 1px solid $offensive_border; border: 1px solid $offensive_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $offensive_border; background-color: $offensive_border;
} }
@ -1426,7 +1454,7 @@ select {
border: 1px solid $offensive_border_faint; border: 1px solid $offensive_border_faint;
background-color: $offensive_color_faint; background-color: $offensive_color_faint;
&:hover { &.hover:hover {
background-color: $offensive_border_faint; background-color: $offensive_border_faint;
} }
} }
@ -1441,7 +1469,7 @@ select {
background-color: $starts_as_villager_color; background-color: $starts_as_villager_color;
border: 1px solid $starts_as_villager_border; border: 1px solid $starts_as_villager_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $starts_as_villager_border; background-color: $starts_as_villager_border;
} }
@ -1450,7 +1478,7 @@ select {
border: 1px solid $starts_as_villager_border_faint; border: 1px solid $starts_as_villager_border_faint;
background-color: $starts_as_villager_color_faint; background-color: $starts_as_villager_color_faint;
&:hover { &.hover:hover {
background-color: $starts_as_villager_border_faint; background-color: $starts_as_villager_border_faint;
} }
} }
@ -1465,7 +1493,7 @@ select {
background-color: $traitor_color; background-color: $traitor_color;
border: 1px solid $traitor_border; border: 1px solid $traitor_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $traitor_border; background-color: $traitor_border;
} }
@ -1474,7 +1502,7 @@ select {
border: 1px solid $traitor_border_faint; border: 1px solid $traitor_border_faint;
background-color: $traitor_color_faint; background-color: $traitor_color_faint;
&:hover { &.hover:hover {
background-color: $traitor_border_faint; background-color: $traitor_border_faint;
} }
} }
@ -1484,7 +1512,7 @@ select {
background-color: $drunk_color; background-color: $drunk_color;
border: 1px solid $drunk_border; border: 1px solid $drunk_border;
&:hover { &.hover:hover {
color: white; color: white;
background-color: $drunk_border; background-color: $drunk_border;
} }
@ -1493,7 +1521,7 @@ select {
border: 1px solid $drunk_border_faint; border: 1px solid $drunk_border_faint;
background-color: $drunk_color_faint; background-color: $drunk_color_faint;
&:hover { &.hover:hover {
background-color: $drunk_border_faint; background-color: $drunk_border_faint;
} }
} }
@ -1543,6 +1571,10 @@ select {
} }
.setup-screen { .setup-screen {
.inactive {
filter: brightness(0%);
}
margin-top: 2%; margin-top: 2%;
font-size: 1.5vw; font-size: 1.5vw;
@ -1693,11 +1725,6 @@ li.choice {
} }
} }
.inactive {
// filter: grayscale(100%) brightness(30%);
filter: brightness(0%);
}
.qrcode { .qrcode {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -1938,50 +1965,66 @@ li.choice {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: baseline; align-items: center;
align-content: baseline; align-content: baseline;
justify-content: baseline;
justify-items: baseline; justify-items: baseline;
gap: 1ch;
padding-top: 5px; padding-top: 2px;
padding-bottom: 5px; padding-bottom: 2px;
padding-left: 5px; padding-left: 2px;
padding-right: 10px; padding-right: 2px;
&:has(.killer) { .inactive {
border: 1px solid rgba(212, 85, 0, 0.5); filter: grayscale(100%);
border: none;
} }
&:has(.powerful) { // img {
border: 1px solid rgba(0, 173, 193, 0.5); // vertical-align: sub;
// }
} }
&:has(.inactive) { .alignment-eq,
border: 1px solid rgba(255, 255, 255, 0.3); .roleblock-span {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1ch;
align-items: center;
} }
img { .highlight-span {
vertical-align: sub; height: max-content;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
gap: 5px;
word-wrap: normal;
.name {
color: white;
} }
&.execution { .dead {
border: 1px solid rgba(255, 255, 255, 0.3); text-decoration: line-through;
font-style: italic;
}
} }
.alignment-eq { &:hover::after {
img { content: attr(role);
vertical-align: sub; overflow-y: hidden;
position: absolute;
margin-top: 60px;
color: white;
background-color: black;
border: 1px solid white;
padding: 3px;
z-index: 4;
align-self: center;
justify-self: center;
} }
border: 1px solid $intel_border;
background-color: color.change($intel_color, $alpha: 0.1);
padding-top: 5px;
padding-bottom: 5px;
padding-left: 10px;
padding-right: 10px;
} }
.character-span { .character-span {
@ -1990,7 +2033,12 @@ li.choice {
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
gap: 5px; gap: 1ch;
.dead {
text-decoration: line-through;
font-style: italic;
}
.number { .number {
color: rgba(255, 255, 0, 0.7); color: rgba(255, 255, 0, 0.7);
@ -2699,6 +2747,29 @@ dialog::backdrop {
gap: 5px; gap: 5px;
} }
.tabs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
// gap: 3px;
margin: 0;
align-self: flex-start;
width: 100%;
.tab-button:not(.selected) {
flex-grow: 1;
// color: white;
cursor: pointer;
}
.tab-button {
color: white;
flex-grow: 1;
cursor: pointer;
text-shadow: 2px 2px black;
}
}
.story { .story {
display: flex; display: flex;
@ -2707,13 +2778,14 @@ dialog::backdrop {
// width: 100vw; // width: 100vw;
justify-content: space-evenly; justify-content: space-evenly;
row-gap: 5px; row-gap: 5px;
margin: 5vh 10vw 0px 10vw; margin: 5vh 5vw 0px 5vw;
.character-headline { .character-headline {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 3px; gap: 3px;
align-items: center; align-items: center;
cursor: pointer;
.icon-spacer { .icon-spacer {
height: 32px; height: 32px;
@ -2729,13 +2801,48 @@ dialog::backdrop {
} }
} }
.character-details {
display: none;
&.shown { .actions {
width: 100%;
// min-height: 30vh;
display: flex; display: flex;
flex-direction: column;
} }
.no-content {
filter: grayscale(60%);
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: default;
// backdrop-filter: brightness(20%);
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
.sub-headline {
display: none;
// margin-left: 0.5cm;
// font-size: 1.5em;
// font-style: italic;
}
.action,
.change {
font-size: 1.25em;
display: flex;
flex-direction: row;
gap: 1ch;
flex-wrap: wrap;
align-items: center;
}
.character-details {
display: flex;
flex-grow: 1;
border-top: none; border-top: none;
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -2760,11 +2867,7 @@ dialog::backdrop {
} }
.details { .details {
display: none;
&.shown {
display: flex; display: flex;
}
flex-direction: column; flex-direction: column;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -2805,3 +2908,59 @@ dialog {
} }
} }
} }
.wolves-highlight {
color: $wolves_color;
.number {
color: $wolves_border;
}
}
.village-highlight {
color: $village_color;
.number {
color: $village_border;
}
}
.intel-highlight {
color: $intel_color;
.number {
color: $intel_border;
}
}
.defensive-highlight {
color: $defensive_color;
.number {
color: $defensive_border;
}
}
.offensive-highlight {
color: $offensive_color;
.number {
color: $offensive_border;
}
}
.starts-as-villager-highlight {
color: $starts_as_villager_color;
.number {
color: $starts_as_villager_border;
}
}
.traitor-highlight {
color: $traitor_color;
.number {
color: $traitor_color;
}
}

View File

@ -1,4 +1,4 @@
// Copyright (C) 2025 Emilis Bliūdžius // Copyright (C) 2026 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as // it under the terms of the GNU Affero General Public License as
@ -12,6 +12,22 @@
// //
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use werewolves_proto::{character::Character, game::SetupRole, team::Team};
pub trait Class { pub trait Class {
fn class(&self) -> Option<&'static str>; fn class(&self) -> Option<&'static str>;
} }
impl Class for Character {
fn class(&self) -> Option<&'static str> {
if let Team::AnyEvil = self.team() {
return Some("traitor");
}
Some(
Into::<SetupRole>::into(self.role_title())
.category()
.class(),
)
}
}

View File

@ -1,4 +1,4 @@
// Copyright (C) 2025 Emilis Bliūdžius // Copyright (C) 2026 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as // it under the terms of the GNU Affero General Public License as
@ -29,15 +29,13 @@ pub struct AlignmentSpanProps {
#[function_component] #[function_component]
pub fn AlignmentSpan(AlignmentSpanProps { alignment }: &AlignmentSpanProps) -> Html { pub fn AlignmentSpan(AlignmentSpanProps { alignment }: &AlignmentSpanProps) -> Html {
let class = match alignment { let class = match alignment {
role::Alignment::Village => "village", role::Alignment::Village => "village-highlight",
role::Alignment::Wolves => "wolves", role::Alignment::Wolves => "wolves-highlight",
role::Alignment::Traitor => "traitor", role::Alignment::Traitor => "traitor-highlight",
}; };
html! { html! {
<span class={classes!("attribute-span", "faint", class)}> <span class={classes!("attribute-span", class)}>
<div> <Icon source={alignment.icon()} icon_type={IconType::Fit}/>
<Icon source={alignment.icon()} icon_type={IconType::Small}/>
</div>
{alignment.to_string()} {alignment.to_string()}
</span> </span>
} }

View File

@ -29,20 +29,14 @@ pub fn AlignmentComparisonSpan(
match comparison { match comparison {
AlignmentEq::Same => html! { AlignmentEq::Same => html! {
<span class="alignment-eq"> <span class="alignment-eq">
<div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div> <Icon source={IconSource::Equal} icon_type={IconType::Fit}/>
<div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div>
{"the same"} {"the same"}
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
</span> </span>
}, },
AlignmentEq::Different => html! { AlignmentEq::Different => html! {
<span class="alignment-eq"> <span class="alignment-eq">
<div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div> <Icon source={IconSource::NotEqual} icon_type={IconType::Fit}/>
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
{"different"} {"different"}
<div><Icon source={IconSource::Wolves} icon_type={IconType::Small}/></div>
<div><Icon source={IconSource::Village} icon_type={IconType::Small}/></div>
</span> </span>
}, },
} }

View File

@ -25,29 +25,10 @@ pub struct DiedToSpanProps {
#[function_component] #[function_component]
pub fn DiedToSpan(DiedToSpanProps { died_to }: &DiedToSpanProps) -> Html { pub fn DiedToSpan(DiedToSpanProps { died_to }: &DiedToSpanProps) -> Html {
let class = match died_to {
DiedToTitle::Execution => "execution",
DiedToTitle::MapleWolfStarved | DiedToTitle::MapleWolf => {
SetupRoleTitle::MapleWolf.category().class()
}
DiedToTitle::Militia => SetupRoleTitle::Militia.category().class(),
DiedToTitle::LoneWolf
| DiedToTitle::AlphaWolf
| DiedToTitle::Shapeshift
| DiedToTitle::Wolfpack => SetupRoleTitle::Werewolf.category().class(),
DiedToTitle::Hunter => SetupRoleTitle::Hunter.category().class(),
DiedToTitle::GuardianProtecting => SetupRoleTitle::Guardian.category().class(),
DiedToTitle::PyreMasterLynchMob | DiedToTitle::PyreMaster => {
SetupRoleTitle::PyreMaster.category().class()
}
DiedToTitle::MasonLeaderRecruitFail => SetupRoleTitle::MasonLeader.category().class(),
};
let icon = died_to.icon().unwrap_or(IconSource::Skull); let icon = died_to.icon().unwrap_or(IconSource::Skull);
html! { html! {
<span class={classes!("attribute-span", "faint", class)}> <span class={classes!("attribute-span",)}>
<div> <Icon source={icon} icon_type={IconType::Fit}/>
<Icon source={icon} icon_type={IconType::Small}/>
</div>
{died_to.to_string().to_case(Case::Title)} {died_to.to_string().to_case(Case::Title)}
</span> </span>
} }

View File

@ -29,10 +29,12 @@ pub fn KillerSpan(KillerSpanProps { killer }: &KillerSpanProps) -> Html {
Killer::NotKiller => "inactive", Killer::NotKiller => "inactive",
}; };
html! { html! {
<span class={classes!("attribute-span", "faint")}> <span class={classes!("attribute-span")}>
<div class={classes!(class)}> <Icon
<Icon source={killer.icon()} icon_type={IconType::Small}/> source={killer.icon()}
</div> icon_type={IconType::Fit}
classes={classes!(class)}
/>
{killer.to_string()} {killer.to_string()}
</span> </span>
} }

View File

@ -29,10 +29,12 @@ pub fn PowerfulSpan(PowerfulSpanProps { powerful }: &PowerfulSpanProps) -> Html
Powerful::NotPowerful => "inactive", Powerful::NotPowerful => "inactive",
}; };
html! { html! {
<span class={classes!("attribute-span", "faint")}> <span class={classes!("attribute-span")}>
<div class={classes!(class)}> <Icon
<Icon source={powerful.icon()} icon_type={IconType::Small}/> source={powerful.icon()}
</div> icon_type={IconType::Fit}
classes={classes!(class)}
/>
{powerful.to_string()} {powerful.to_string()}
</span> </span>
} }

View File

@ -1,3 +1,5 @@
use core::ops::Not;
// Copyright (C) 2025 Emilis Bliūdžius // Copyright (C) 2025 Emilis Bliūdžius
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
@ -13,10 +15,17 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use convert_case::{Case, Casing}; use convert_case::{Case, Casing};
use werewolves_proto::{character::Character, game::SetupRole, message::CharacterIdentity}; use werewolves_proto::{
character::Character,
game::{GameTime, SetupRole},
message::CharacterIdentity,
};
use yew::prelude::*; use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType, PartialAssociatedIcon}; use crate::{
class::Class,
components::{Icon, IconSource, IconType, PartialAssociatedIcon},
};
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterCardProps { pub struct CharacterCardProps {
@ -89,3 +98,47 @@ pub fn CharacterTargetCard(
</span> </span>
} }
} }
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterHighlightProps {
pub char: Character,
pub time: GameTime,
}
#[function_component]
pub fn CharacterHighlight(
CharacterHighlightProps { char, time }: &CharacterHighlightProps,
) -> Html {
let class = char.class().map(|c| format!("{c}-highlight"));
let icon = char
.role_title()
.icon()
.map(|source| {
html! {
<Icon source={source} icon_type={IconType::Fit}/>
}
})
.unwrap_or(html! {
<div class="icon-spacer"/>
});
let dead = char.died_to().and_then(|died_to| {
let night = died_to.night();
let day = died_to.day();
match (time, day, night) {
(GameTime::Day { number }, Some(day), None) => number.get() > day.get(),
(GameTime::Night { number }, None, Some(night)) => *number > night,
(GameTime::Day { number }, None, Some(night)) => number.get() > night,
(GameTime::Night { number }, Some(day), None) => *number >= day.get(),
(_, None, None) | (_, Some(_), Some(_)) => true,
}
.then_some("dead")
});
let role_text = char.role_title().to_string().to_case(Case::Title);
html! {
<span class={classes!("highlight-span", class)} role={role_text}>
{icon}
<span class={classes!("number")}><b>{char.number().get()}</b></span>
<span class={classes!("name", dead)}>{char.name()}</span>
</span>
}
}

View File

@ -0,0 +1,66 @@
// 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 werewolves_proto::{game::SetupRole, role::RoleTitle};
use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType, PartialAssociatedIcon};
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct RoleSpanProps {
pub role: RoleTitle,
}
#[function_component]
pub fn RoleSpan(RoleSpanProps { role }: &RoleSpanProps) -> Html {
let class = Into::<SetupRole>::into(*role).category().class();
let icon = role
.icon()
.map(|icon| {
html! {
<Icon source={icon} icon_type={IconType::Small}/>
}
})
.unwrap_or(html! {<div class="icon-spacer"/>});
let role_name = role.to_string().to_case(Case::Title);
html! {
<span class={classes!("role-span", class, "faint")}>
{icon}
<span class="role-name">{role_name}</span>
</span>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct RoleblockProps {
#[prop_or_default]
pub children: Html,
}
#[function_component]
pub fn Roleblock(RoleblockProps { children }: &RoleblockProps) -> Html {
let content = if *children == html! {} {
html! {<span>{"Drunk"}</span>}
} else {
children.clone()
};
html! {
<span class={classes!("roleblock-span")}>
<Icon source={IconSource::Roleblock} icon_type={IconType::Fit}/>
{content}
</span>
}
}

View File

@ -13,85 +13,152 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use core::ops::Not;
use std::{collections::HashMap, rc::Rc}; use std::{collections::HashMap, rc::Rc};
use werewolves_proto::{ use werewolves_proto::{
aura::AuraTitle,
character::{Character, CharacterId}, character::{Character, CharacterId},
diedto::DiedTo,
game::{ game::{
GameTime, SetupRole, GameTime, SetupRole,
night::changes::NightChange, night::changes::NightChange,
story::{NightChoice, StoryActionPrompt}, story::{NightChoice, StoryActionPrompt, StoryActionResult},
}, },
player::Protection,
role::{RoleBlock, RoleTitle},
}; };
use yew::prelude::*; use yew::prelude::*;
use crate::components::{Icon, IconSource, IconType, Identity, PartialAssociatedIcon}; use crate::{
class::Class,
components::{
AuraSpan, Button, CharacterHighlight, Icon, IconSource, IconType, Identity, IdentitySpan,
PartialAssociatedIcon, RoleSpan, Roleblock,
attributes::{
AlignmentComparisonSpan, AlignmentSpan, DiedToSpan, KillerSpan, PowerfulSpan,
},
},
};
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
pub struct CharacterStoryProps { pub struct CharacterStoryProps {
pub all_characters: Rc<[Character]>,
pub character: Character, pub character: Character,
pub actions: Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>, pub actions: Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
} }
#[function_component] #[function_component]
pub fn CharacterStory(CharacterStoryProps { character, actions }: &CharacterStoryProps) -> Html { pub fn CharacterStory(
let mut by_time = actions.clone(); CharacterStoryProps {
by_time.sort_by_key(|s| s.0); all_characters,
let by_time = by_time
.into_iter()
.map(|(time, changes, choices)| {
html! {
<CharacterStoryTime
character={character.clone()}
time={time}
changes={changes}
choices={choices}
/>
}
})
.collect::<Html>();
html! {
<div class="character-story">
<CharacterStoryContainer character={character.clone()}>
{by_time}
</CharacterStoryContainer>
</div>
}
}
#[derive(Debug, Clone, PartialEq, Properties)]
struct CharacterStoryTimeProps {
pub character: Character,
pub time: GameTime,
pub changes: Box<[NightChange]>,
pub choices: Box<[NightChoice]>,
}
#[function_component]
fn CharacterStoryTime(
CharacterStoryTimeProps {
character, character,
time, actions,
changes, }: &CharacterStoryProps,
choices,
}: &CharacterStoryTimeProps,
) -> Html { ) -> Html {
let open = use_state(|| false); let class = character.class();
let mut by_time = actions
.iter()
.filter(|(_, changes, choices)| !(changes.is_empty() && choices.is_empty()))
.cloned()
.collect::<Box<[_]>>();
by_time.sort_by_key(|s| s.0);
let open_time = use_state(|| by_time.iter().next().map(|(t, _, _)| *t));
if let Some(current_open_time) = open_time.as_ref()
&& let Some(last) = by_time.last().map(|c| c.0)
{
if last < *current_open_time || !by_time.iter().any(|(t, _, _)| t == current_open_time) {
open_time.set(Some(last));
} else if let Some(first) = by_time.first().map(|c| c.0)
&& first > *current_open_time
{
open_time.set(Some(first));
}
}
let tabs = by_time
.into_iter()
.map(|(time, _, _)| {
let time_text = match time { let time_text = match time {
GameTime::Day { number } => format!("day {number}"), GameTime::Day { number } => format!("day {number}"),
GameTime::Night { number } => format!("night {number}"), GameTime::Night { number } => format!("night {number}"),
}; };
let shown = open.then_some("shown");
let on_click = { let on_click = {
let open = open.clone(); let open_time = open_time.setter();
Callback::from(move |_| open.set(!*open)) Callback::from(move |_| open_time.set(Some(time)))
}; };
let selected = open_time
.as_ref()
.map(|open| *open == time)
.unwrap_or_default();
let faint = selected.not().then_some("faint");
let selected = selected.then_some("selected");
html! { html! {
<div class="story-time"> <button
<span class={classes!("time")} onclick={on_click}> onclick={on_click}
class={classes!("tab-button", class, faint, selected, "hover")}
>
{time_text} {time_text}
</span> </button>
<div class={classes!("details", shown)}> }
{"hello"} })
.collect::<Html>();
let story_content = open_time
.as_ref()
.and_then(|time| actions.iter().find(|(t, _, _)| t == time))
.map(|(time, changes, choices)| {
let choices = choices
.iter()
.map(|c| {
html! {
<Choice
all_characters={all_characters.clone()}
character={character.clone()}
choice={c.clone()}
all_choices_that_night={choices.clone()}
time={*time}
/>
}
})
.collect::<Html>();
let changes = changes
.iter()
.map(|c| {
html! {
<Change all_characters={all_characters.clone()} change={c.clone()} time={*time}/>
}
})
.collect::<Html>();
let changes = (changes == html! {}).not().then_some(html! {
<>
<span class="sub-headline">{"changes"}</span>
<div>
{changes}
</div>
</>
});
let choices = (choices == html! {}).not().then_some(html! {
<>
<span class="sub-headline">{"choices"}</span>
<div>
{choices}
</div>
</>
});
html! {
<>
{choices}
{changes}
</>
}
});
html! {
<div class="character-story">
<div class="tabs">
{tabs}
</div>
<div class="story-content">
{story_content}
</div> </div>
</div> </div>
} }
@ -102,6 +169,8 @@ struct ChoiceProps {
all_characters: Rc<[Character]>, all_characters: Rc<[Character]>,
character: Character, character: Character,
choice: NightChoice, choice: NightChoice,
all_choices_that_night: Box<[NightChoice]>,
time: GameTime,
} }
#[function_component] #[function_component]
@ -110,45 +179,258 @@ fn Choice(
all_characters, all_characters,
character, character,
choice, choice,
all_choices_that_night,
time,
}: &ChoiceProps, }: &ChoiceProps,
) -> Html { ) -> Html {
let generate_prompt = |chosen: CharacterId| -> Html { todo!() }; let generate_prompt = |chosen: CharacterId, wording: &'static str| -> Option<Html> {
all_characters
.iter()
.find(|c| c.character_id() == chosen)
.map(|chosen| {
html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{wording}</span>
<CharacterHighlight
char={chosen.clone()}
time={*time}
/>
</>
}
})
};
let prompt = match &choice.prompt { let prompt = match &choice.prompt {
StoryActionPrompt::Guardian { StoryActionPrompt::Guardian {
chosen, guarding, .. chosen, guarding, ..
} => todo!(), } => generate_prompt(*chosen, if *guarding { "guarded" } else { "protected" }),
StoryActionPrompt::Seer { chosen, .. } => todo!(), StoryActionPrompt::Seer { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::Protector { chosen, .. } => todo!(), StoryActionPrompt::Protector { chosen, .. } => generate_prompt(*chosen, "protected"),
StoryActionPrompt::Arcanist { chosen, .. } => todo!(), StoryActionPrompt::Arcanist {
StoryActionPrompt::Gravedigger { chosen, .. } => todo!(), chosen: (target1, target2),
StoryActionPrompt::Hunter { chosen, .. } => todo!(), ..
StoryActionPrompt::Militia { chosen, .. } => todo!(), } => all_characters
StoryActionPrompt::MapleWolf { chosen, .. } => todo!(), .iter()
StoryActionPrompt::Adjudicator { chosen, .. } => todo!(), .find(|c| c.character_id() == *target1)
StoryActionPrompt::PowerSeer { chosen, .. } => todo!(), .and_then(|t1| {
StoryActionPrompt::Mortician { chosen, .. } => todo!(), all_characters
StoryActionPrompt::Beholder { chosen, .. } => todo!(), .iter()
StoryActionPrompt::MasonsWake { leader, masons } => todo!(), .find(|c| c.character_id() == *target2)
StoryActionPrompt::MasonLeaderRecruit { chosen, .. } => todo!(), .map(|t2| (t1, t2))
StoryActionPrompt::Empath { chosen, .. } => todo!(), })
StoryActionPrompt::Vindicator { chosen, .. } => todo!(), .map(|(t1, t2)| {
StoryActionPrompt::PyreMaster { chosen, .. } => todo!(), html! {
StoryActionPrompt::WolfPackKill { chosen } => todo!(), <>
StoryActionPrompt::Shapeshifter { .. } => todo!(), <CharacterHighlight
StoryActionPrompt::AlphaWolf { chosen, .. } => todo!(), char={character.clone()}
StoryActionPrompt::DireWolf { chosen, .. } => todo!(), time={*time}
StoryActionPrompt::LoneWolfKill { chosen, .. } => todo!(), />
StoryActionPrompt::Insomniac { .. } => todo!(), <span>{"checked"}</span>
StoryActionPrompt::Bloodletter { chosen, .. } => todo!(), <CharacterHighlight
StoryActionPrompt::BeholderWakes { character_id } => todo!(), char={t1.clone()}
time={*time}
/>
<span>{"and"}</span>
<CharacterHighlight
char={t2.clone()}
time={*time}
/>
</>
}
}),
StoryActionPrompt::Gravedigger { chosen, .. } => generate_prompt(*chosen, "dug"),
StoryActionPrompt::Hunter { chosen, .. } => generate_prompt(*chosen, "set a trap for"),
StoryActionPrompt::Militia { chosen, .. } => generate_prompt(*chosen, "shot"),
StoryActionPrompt::MapleWolf { chosen, .. } => generate_prompt(*chosen, "ate"),
StoryActionPrompt::Adjudicator { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::PowerSeer { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::Mortician { chosen, .. } => generate_prompt(*chosen, "examined"),
StoryActionPrompt::Beholder { chosen, .. } => generate_prompt(*chosen, "beheld"),
StoryActionPrompt::MasonLeaderRecruit { chosen, .. } => {
generate_prompt(*chosen, "attempted to recruit")
}
StoryActionPrompt::WolfPackKill { chosen, .. } => {
generate_prompt(*chosen, "took the wolfpack kill and attacked")
}
StoryActionPrompt::MasonsWake { leader, .. } => all_characters
.iter()
.find(|l| l.character_id() == *leader)
.map(|leader| {
html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{"woke with the masons of"}</span>
<CharacterHighlight
char={leader.clone()}
time={*time}
/>
</>
}
}),
StoryActionPrompt::Empath { chosen, .. } => generate_prompt(*chosen, "checked"),
StoryActionPrompt::Vindicator { chosen, .. } => generate_prompt(*chosen, "protected"),
StoryActionPrompt::PyreMaster { chosen, .. } => generate_prompt(*chosen, "burned"),
StoryActionPrompt::Shapeshifter { .. } => all_choices_that_night
.iter()
.find_map(|c| match c.prompt {
StoryActionPrompt::WolfPackKill { chosen, .. } => Some(chosen),
_ => None,
})
.and_then(|target| all_characters.iter().find(|c| c.character_id() == target))
.map(|target| {
html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{"chose to shift into"}</span>
<CharacterHighlight
char={target.clone()}
time={*time}
/>
</>
}
}),
StoryActionPrompt::Insomniac { .. } => Some(html! {
<>
<CharacterHighlight
char={character.clone()}
time={*time}
/>
<span>{"woke in the night due to visits from: "}</span>
</>
}),
StoryActionPrompt::AlphaWolf { chosen, .. } => generate_prompt(*chosen, "killed"),
StoryActionPrompt::DireWolf { chosen, .. } => {
generate_prompt(*chosen, "roleblocked visitors to")
}
StoryActionPrompt::LoneWolfKill { chosen, .. } => generate_prompt(*chosen, "killed"),
StoryActionPrompt::Bloodletter { chosen, .. } => generate_prompt(*chosen, "bloodlet"),
StoryActionPrompt::BeholderWakes { character_id } => {
generate_prompt(*character_id, "woke again to see")
}
}; };
html! {} if prompt.is_none() {
return html! {};
}
let result = choice.result.as_ref().map(|result| match result {
StoryActionResult::RoleBlocked => html! {
<>
<span>{"but was"}</span>
<Roleblock>{"Roleblocked"}</Roleblock>
</>
},
StoryActionResult::Seer(alignment) => html! {
<>
<span>{"and saw"}</span>
<AlignmentSpan alignment={*alignment}/>
</>
},
StoryActionResult::PowerSeer { powerful } => html! {
<>
<span>{"and saw"}</span>
<PowerfulSpan powerful={*powerful}/>
</>
},
StoryActionResult::Adjudicator { killer } => html! {
<>
<span>{"and saw"}</span>
<KillerSpan killer={*killer}/>
</>
},
StoryActionResult::Arcanist(alignment_eq) => html! {
<>
<span>{"and saw them as"}</span>
<AlignmentComparisonSpan comparison={*alignment_eq}/>
</>
},
StoryActionResult::GraveDigger(None) => html! {
<span>{"but found an empty grave"}</span>
},
StoryActionResult::GraveDigger(Some(role_title)) => html! {
<>
<span>{"as"}</span>
<RoleSpan role={*role_title}/>
</>
},
StoryActionResult::Mortician(died_to_title) => html! {
<>
<span>{"and found"}</span>
<DiedToSpan died_to={*died_to_title}/>
<span>{"to be the cause of death"}</span>
</>
},
StoryActionResult::Insomniac { visits } => {
let mut visit_chars = vec![];
for visit in visits {
match all_characters.iter().find(|c| c.character_id() == *visit) {
Some(char) => visit_chars.push(char),
None => {
log::warn!("visit chars missing {visit}");
return html! {};
}
}
}
visit_chars
.into_iter()
.map(|visitor| {
html! {
<CharacterHighlight
char={visitor.clone()}
time={*time}
/>
}
})
.collect::<Html>()
}
StoryActionResult::Empath { scapegoat: true } => {
html! {<span>{"and found their scapegoat"}</span>}
}
StoryActionResult::Empath { scapegoat: false } => {
html! {<span>{"who was not a scapegoat"}</span>}
}
StoryActionResult::BeholderSawNothing => html! {
<>
{"and saw"}
<em>{"nothing"}</em>
</>
},
StoryActionResult::BeholderSawEverything => html! {
<>
{"and saw"}
<em>{"everything"}</em>
</>
},
StoryActionResult::ShiftFailed => html! {
{"however, their shift failed"}
},
StoryActionResult::Drunk => html! {
<>
<span>{"but got"}</span>
<AuraSpan aura={AuraTitle::Drunk} />
</>
},
});
html! {
<div class="action">
{prompt}
{result}
</div>
}
} }
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
struct ChangeProps { struct ChangeProps {
all_characters: Rc<[Character]>, all_characters: Rc<[Character]>,
change: NightChange, change: NightChange,
time: GameTime,
} }
#[function_component] #[function_component]
@ -156,53 +438,236 @@ fn Change(
ChangeProps { ChangeProps {
all_characters, all_characters,
change, change,
time,
}: &ChangeProps, }: &ChangeProps,
) -> Html { ) -> Html {
match change { match change {
NightChange::RoleChange(role_title, ..) => todo!(), NightChange::RoleChange(_, role_title) => Some(html! {
NightChange::Kill { target, died_to } => todo!(), <>
<span>{"role changed to"}</span>
<RoleSpan role={*role_title}/>
</>
}),
NightChange::Kill { died_to, .. } => Some(html! {
<>
<span>{"died to"}</span>
<DiedToSpan died_to={died_to.title()}/>
</>
}),
NightChange::RoleBlock { NightChange::RoleBlock {
source, source,
target, block_type: RoleBlock::Direwolf,
block_type, ..
} => todo!(), } => all_characters
NightChange::Shapeshift { source, into } => todo!(), .iter()
NightChange::Protection { target, protection } => todo!(), .find(|s| s.character_id() == *source)
NightChange::ElderReveal { .. } => todo!(), .map(|source| {
html! {
<>
<span>{"had visitors role blocked by"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
NightChange::Shapeshift { source, .. } => all_characters
.iter()
.find(|c| c.character_id() == *source)
.map(|source| {
html! {
<>
{"shapeshifted by"}
<CharacterHighlight
char={source.clone()}
time={*time}
/>
{"and became a werewolf"}
</>
}
}),
NightChange::ElderReveal { .. } => Some(html! {
<>
<span>{"learned they are the"}</span>
<RoleSpan role={RoleTitle::Elder}/>
</>
}),
NightChange::MasonRecruit { NightChange::MasonRecruit {
mason_leader, mason_leader,
recruiting, recruiting,
} => todo!(), } => all_characters
NightChange::ApplyAura { source, aura, .. } => todo!(), .iter()
NightChange::LostAura { aura, .. } => todo!(), .find(|c| c.character_id() == *mason_leader)
NightChange::EmpathFoundScapegoat { .. } | NightChange::HunterTarget { .. } => { .and_then(|mason| {
return html! {}; all_characters
.iter()
.find(|c| c.character_id() == *recruiting)
.map(|recruiting| (mason, recruiting))
})
.map(|(mason, recruiting)| {
html! {
<>
<CharacterHighlight
char={mason.clone()}
time={*time}
/>
<span>{"recruited"}</span>
<CharacterHighlight
char={recruiting.clone()}
time={*time}
/>
<span>{"into the masons"}</span>
</>
}
}),
NightChange::ApplyAura { source, aura, .. } => {
let from = all_characters
.iter()
.find(|c| c.character_id() == *source)
.map(|source| {
html! {
<>
<span>{"from"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
});
Some(html! {
<>
<span>{"received the"}</span>
<AuraSpan aura={aura.title()}/>
<span>{"aura"}</span>
{from}
</>
})
}
NightChange::LostAura { aura, .. } => Some(html! {
<>
<span>{"lost the"}</span>
<AuraSpan aura={aura.title()}/>
<span>{"aura"}</span>
</>
}),
NightChange::EmpathFoundScapegoat { scapegoat, .. } => all_characters
.iter()
.find(|c| c.character_id() == *scapegoat)
.map(|scapegoat| {
html! {
<>
<span>{"took on the scapegoat's curse from"}</span>
<CharacterHighlight
char={scapegoat.clone()}
time={*time}
/>
</>
}
}),
NightChange::HunterTarget { source, .. } => all_characters
.iter()
.find(|c| c.character_id() == *source)
.map(|source| {
html! {
<>
{"had the hunter's mark placed on them by"}
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
NightChange::Protection { target, protection } => {
let prot = match protection {
Protection::Guardian {
source,
guarding: true,
} => all_characters
.iter()
.find(|s| s.character_id() == *source)
.map(|source| {
html! {
<>
<span>{"was guarded by"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
Protection::Guardian {
source,
guarding: false,
}
| Protection::Protector { source }
| Protection::Vindicator { source } => all_characters
.iter()
.find(|s| s.character_id() == *source)
.map(|source| {
html! {
<>
<span>{"was protected by"}</span>
<CharacterHighlight
char={source.clone()}
time={*time}
/>
</>
}
}),
};
all_characters
.iter()
.find(|t| t.character_id() == *target)
.and_then(|target| prot.map(|prot| (target, prot)))
.map(|(target, prot)| {
html! {
<>
<CharacterHighlight
char={target.clone()}
time={*time}
/>
{prot}
</>
}
})
} }
} }
.map(|change| {
html! { html! {
<div class="change"> <div class="change">
{change}
</div> </div>
} }
})
.unwrap_or_default()
} }
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
struct CharacterStoryContainerProps { pub struct CharacterStoryButtonProps {
pub character: Character, pub character: Character,
#[prop_or_default] pub choices_by_time: Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
pub children: Html, pub on_click: Callback<(
Character,
Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
)>,
} }
#[function_component] #[function_component]
fn CharacterStoryContainer( pub fn CharacterStoryButton(
CharacterStoryContainerProps { CharacterStoryButtonProps {
character, character,
children, choices_by_time,
}: &CharacterStoryContainerProps, on_click,
}: &CharacterStoryButtonProps,
) -> Html { ) -> Html {
let open = use_state(|| true); let clickable = !choices_by_time
let role_class = Into::<SetupRole>::into(character.role_title()) .iter()
.category() .all(|(_, c1, c2)| c1.is_empty() && c2.is_empty());
.class(); let role_class = character.class();
let icon = character let icon = character
.role_title() .role_title()
.icon() .icon()
@ -225,21 +690,21 @@ fn CharacterStoryContainer(
<div class="icon-spacer"/> <div class="icon-spacer"/>
}); });
let on_click = { let on_click = if clickable {
let open = open.clone(); let cb = on_click.clone();
Callback::from(move |_| open.set(!*open)) let char = character.clone();
let act = choices_by_time.clone();
Callback::from(move |_| cb.emit((char.clone(), act.clone())))
} else {
Callback::noop()
}; };
let shown = open.then_some("shown"); let inactive = clickable.not().then_some("no-content");
let hover = clickable.then_some("hover");
html! { html! {
<div class="character-story-card"> <div class={classes!("character-headline", role_class, "faint", inactive, hover)} onclick={on_click}>
<div class={classes!("character-headline", role_class, "faint")} onclick={on_click}>
{icon} {icon}
<Identity ident={character.identity().into_public()}/> <Identity ident={character.identity().into_public()}/>
{dead_icon} {dead_icon}
</div> </div>
<div class={classes!("character-details", role_class, "faint", shown)}>
{children.clone()}
</div>
</div>
} }
} }

View File

@ -35,6 +35,7 @@ pub fn generate_story() -> GameStory {
empath, empath,
scapegoat, scapegoat,
hunter, hunter,
mason_leader,
) = ( ) = (
(SetupRole::Werewolf, players_iter.next().unwrap()), (SetupRole::Werewolf, players_iter.next().unwrap()),
(SetupRole::DireWolf, players_iter.next().unwrap()), (SetupRole::DireWolf, players_iter.next().unwrap()),
@ -59,6 +60,12 @@ pub fn generate_story() -> GameStory {
players_iter.next().unwrap(), players_iter.next().unwrap(),
), ),
(SetupRole::Hunter, players_iter.next().unwrap()), (SetupRole::Hunter, players_iter.next().unwrap()),
(
SetupRole::MasonLeader {
recruits_available: NonZeroU8::new(3).unwrap(),
},
players_iter.next().unwrap(),
),
); );
let mut settings = GameSettings::empty(); let mut settings = GameSettings::empty();
settings.add_and_assign(werewolf.0, werewolf.1); settings.add_and_assign(werewolf.0, werewolf.1);
@ -79,6 +86,7 @@ pub fn generate_story() -> GameStory {
settings.add_and_assign(empath.0, empath.1); settings.add_and_assign(empath.0, empath.1);
settings.add_and_assign(scapegoat.0, scapegoat.1); settings.add_and_assign(scapegoat.0, scapegoat.1);
settings.add_and_assign(hunter.0, hunter.1); settings.add_and_assign(hunter.0, hunter.1);
settings.add_and_assign(mason_leader.0, mason_leader.1);
settings.fill_remaining_slots_with_villagers(players.len()); settings.fill_remaining_slots_with_villagers(players.len());
#[allow(unused)] #[allow(unused)]
let ( let (
@ -100,6 +108,7 @@ pub fn generate_story() -> GameStory {
empath, empath,
scapegoat, scapegoat,
hunter, hunter,
mason_leader,
) = ( ) = (
werewolf.1, werewolf.1,
dire_wolf.1, dire_wolf.1,
@ -119,6 +128,7 @@ pub fn generate_story() -> GameStory {
empath.1, empath.1,
scapegoat.1, scapegoat.1,
hunter.1, hunter.1,
mason_leader.1,
); );
let mut game = Game::new(&players, settings).unwrap(); let mut game = Game::new(&players, settings).unwrap();
game.r#continue().r#continue(); game.r#continue().r#continue();
@ -223,6 +233,13 @@ pub fn generate_story() -> GameStory {
assert!(!game.r#continue().empath()); assert!(!game.r#continue().empath());
game.r#continue().sleep(); game.r#continue().sleep();
game.next().title().masons_leader_recruit();
game.mark(game.character_by_player_id(insomniac).character_id());
game.r#continue().r#continue();
game.next().title().masons_wake();
game.r#continue().sleep();
game.next().title().insomniac(); game.next().title().insomniac();
game.r#continue().insomniac(); game.r#continue().insomniac();
game.r#continue().sleep(); game.r#continue().sleep();
@ -303,6 +320,13 @@ pub fn generate_story() -> GameStory {
assert!(game.r#continue().empath()); assert!(game.r#continue().empath());
game.r#continue().sleep(); game.r#continue().sleep();
game.next().title().masons_leader_recruit();
game.mark(game.character_by_player_id(scapegoat).character_id());
game.r#continue().r#continue();
game.next().title().masons_wake();
game.r#continue().sleep();
game.next().title().insomniac(); game.next().title().insomniac();
game.r#continue().insomniac(); game.r#continue().insomniac();
game.r#continue().sleep(); game.r#continue().sleep();
@ -379,6 +403,13 @@ pub fn generate_story() -> GameStory {
assert_eq!(game.r#continue().mortician(), DiedToTitle::Wolfpack); assert_eq!(game.r#continue().mortician(), DiedToTitle::Wolfpack);
game.r#continue().sleep(); game.r#continue().sleep();
game.next().title().masons_leader_recruit();
game.mark(game.character_by_player_id(shapeshifter).character_id());
game.r#continue().sleep();
game.next().title().masons_wake();
game.r#continue().sleep();
game.next().title().insomniac(); game.next().title().insomniac();
game.r#continue().insomniac(); game.r#continue().insomniac();
game.r#continue().sleep(); game.r#continue().sleep();
@ -435,6 +466,9 @@ pub fn generate_story() -> GameStory {
); );
game.r#continue().sleep(); game.r#continue().sleep();
game.next().title().masons_wake();
game.r#continue().sleep();
game.next().title().insomniac(); game.next().title().insomniac();
game.r#continue().insomniac(); game.r#continue().insomniac();
game.r#continue().sleep(); game.r#continue().sleep();

View File

@ -35,7 +35,7 @@ use crate::components::{
attributes::{ attributes::{
AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan, AlignmentComparisonSpan, AlignmentSpan, CategorySpan, DiedToSpan, KillerSpan, PowerfulSpan,
}, },
story::{CharacterStory, StoryError}, story::{CharacterStory, CharacterStoryButton, StoryError},
}; };
#[derive(Debug, Clone, PartialEq, Properties)] #[derive(Debug, Clone, PartialEq, Properties)]
@ -94,603 +94,54 @@ pub fn Story(StoryProps { story }: &StoryProps) -> Html {
}) })
.collect::<Box<[_]>>(); .collect::<Box<[_]>>();
let selected_char = use_state::<
Option<(
Character,
Box<[(GameTime, Box<[NightChange]>, Box<[NightChoice]>)]>,
)>,
_,
>(|| actions_by_character.first().cloned());
let on_char_pick = {
let selected_char = selected_char.setter();
Callback::from(move |(char, actions)| selected_char.set(Some((char, actions))))
};
let chars = actions_by_character let chars = actions_by_character
.into_iter() .into_iter()
.map(|(char, actions)| { .map(|(char, actions)| {
html! { html! {
<CharacterStory character={char} actions={actions} /> <CharacterStoryButton character={char} choices_by_time={actions} on_click={on_char_pick.clone()} />
} }
}) })
.collect::<Html>(); .collect::<Html>();
let all_characters = village.characters().into_iter().collect::<Rc<_>>();
let actions = selected_char.as_ref().map(|(char, actions)| {
let class = Into::<SetupRole>::into(char.role_title())
.category()
.class();
html! {
<>
<CharacterCard char={char.clone()} dead={char.died_to().is_some()} faint=true/>
<div class={classes!("character-details", class, "faint")}>
<CharacterStory
character={char.clone()}
actions={actions.clone()}
all_characters={all_characters}
/>
</div>
</>
}
});
html! { html! {
<div class="story"> <div class="story">
<div class="story-characters">
{chars} {chars}
</div> </div>
<div class="actions">
{actions}
</div>
</div>
} }
} }
// #[function_component]
// pub fn OldStory(StoryProps { story }: &StoryProps) -> Html {
// let final_characters =
// story
// .final_village()
// .unwrap_or_else(|_| story.starting_village.clone())
// .characters()
// .into_iter()
// .map(|c| {
// let dead =c.alive().not();
// html! {
// <>
// <CharacterCard faint=true char={c} dead={dead}/>
// </>
// }
// })
// .collect::<Html>();
// let bits = story
// .iter()
// .map(|(time, changes)| {
// let characters = story
// .village_at(match time {
// GameTime::Day { .. } => {
// time
// },
// GameTime::Night { .. } => {
// time.previous().unwrap_or(time)
// },
// }).ok().flatten()
// .map(|v| Rc::new(v.characters().into_iter()
// .map(|c| (c.character_id(), c))
// .collect::<HashMap<CharacterId, Character>>()))
// .unwrap_or_else(||
// Rc::new(story.starting_village
// .characters().into_iter()
// .map(|c| (c.character_id(), c))
// .collect::<HashMap<_, _>>()));
// let changes = match changes {
// GameActions::DayDetails(day_changes) => {
// let execute_list =
// day_changes
// .iter()
// .map(|c| match c {
// DayDetail::Execute(target) => *target,
// })
// .filter_map(|c| story.starting_village.character_by_id(c).ok())
// .map(|c| {
// html! {
// <CharacterCard faint=true char={c.clone()}/>
// }
// })
// .collect::<Html>();
// day_changes.is_empty().not().then_some(html! {
// <div class="day">
// <h3>{"village executed"}</h3>
// <div class="executed">
// {execute_list}
// </div>
// </div>
// })
// }
// GameActions::NightDetails(details) => details.choices.is_empty().not().then_some({
// let choices = details
// .choices
// .iter()
// .map(|c| {
// html! {
// <StoryNightChoice choice={c.clone()} characters={characters.clone()}/>
// }
// })
// .collect::<Html>();
// let changes = details
// .changes
// .iter()
// .map(|c| {
// html! {
// <li class="change">
// <StoryNightChange
// change={c.clone()}
// characters={characters.clone()}
// />
// </li>
// }
// })
// .collect::<Html>();
// html! {
// <div class="night">
// <label>{"choices"}</label>
// <ul class="choices">
// {choices}
// </ul>
// <label>{"changes"}</label>
// <ul class="changes">
// {changes}
// </ul>
// </div>
// }
// }),
// };
// changes
// .map(|changes| {
// html! {
// <div class="time-period">
// <h1>{"on "}{time.to_string()}{"..."}</h1>
// {changes}
// </div>
// }
// })
// .unwrap_or_default()
// })
// .collect::<Html>();
// html! {
// <div class="story">
// <div class="cast">
// {final_characters}
// </div>
// {bits}
// </div>
// }
// }
// #[derive(Debug, Clone, PartialEq, Properties)]
// struct StoryNightChangeProps {
// change: NightChange,
// characters: Rc<HashMap<CharacterId, Character>>,
// }
// #[function_component]
// fn StoryNightChange(StoryNightChangeProps { change, characters }: &StoryNightChangeProps) -> Html {
// match change {
// NightChange::LostAura { character, aura } => characters.get(character).map(|character| html!{
// <>
// <CharacterCard faint=true char={character.clone()}/>
// {"lost the"}
// <AuraSpan aura={aura.title()}/>
// {"aura"}
// </>
// }).unwrap_or_default(),
// NightChange::ApplyAura { source, target, aura } => characters.get(source).and_then(|source| characters.get(target).map(|target| (source, target))).map(|(source, target)| {
// html!{
// <>
// <CharacterCard faint=true char={target.clone()}/>
// <span class="story-text">{"gained the"}</span>
// <AuraSpan aura={aura.title()}/>
// <span class="story-text">{"aura from"}</span>
// <CharacterCard faint=true char={source.clone()}/>
// </>
// }
// }).unwrap_or_default(),
// NightChange::RoleChange(character_id, role_title) => characters
// .get(character_id)
// .map(|char| {
// let mut new_char = char.clone();
// let _ = new_char.role_change(*role_title, GameTime::Night { number: 0 });
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{"is now"}</span>
// <CharacterCard faint=true char={new_char.clone()}/>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::Kill { target, died_to } => {
// characters
// .get(target)
// .map(|target| {
// html! {
// <>
// <Icon source={IconSource::Skull} icon_type={IconType::Small}/>
// <CharacterCard faint=true char={target.clone()}/>
// <span class="story-text">{"died to"}</span>
// <DiedToSpan died_to={died_to.title()}/>
// </>
// }
// })
// .unwrap_or_default()
// },
// NightChange::RoleBlock { source, target, .. } => characters
// .get(source)
// .and_then(|s| characters.get(target).map(|t| (s, t)))
// .map(|(source, target)| {
// html! {
// <>
// <CharacterCard faint=true char={source.clone()}/>
// <span class="story-text">{"role blocked"}</span>
// <CharacterCard faint=true char={target.clone()}/>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::Shapeshift { source, into } => characters
// .get(source)
// .and_then(|s| characters.get(into).map(|i| (s, i)))
// .map(|(source, into)| {
// html! {
// <>
// <CharacterCard faint=true char={source.clone()}/>
// <span class="story-text">{"shapeshifted into"}</span>
// <CharacterCard faint=true char={into.clone()}/>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::ElderReveal { elder } => characters
// .get(elder)
// .map(|elder| {
// html! {
// <>
// <CharacterCard faint=true char={elder.clone()}/>
// <span class="story-text">{"learned they are the Elder"}</span>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::EmpathFoundScapegoat { empath, scapegoat } => characters
// .get(empath)
// .and_then(|e| characters.get(scapegoat).map(|s| (e, s)))
// .map(|(empath, scapegoat)| {
// html! {
// <>
// <CharacterCard faint=true char={empath.clone()}/>
// <span class="story-text">{"found the scapegoat in"}</span>
// <CharacterCard faint=true char={scapegoat.clone()}/>
// <span class="story-text">{"and took on their curse"}</span>
// </>
// }
// })
// .unwrap_or_default(),
// NightChange::HunterTarget { .. }
// | NightChange::MasonRecruit { .. }
// | NightChange::Protection { .. } => html! {}, // sorted in prompt side
// }
// }
// #[derive(Debug, Clone, PartialEq, Properties)]
// struct StoryNightResultProps {
// result: StoryActionResult,
// characters: Rc<HashMap<CharacterId, Character>>,
// }
// #[function_component]
// fn StoryNightResult(StoryNightResultProps { result, characters }: &StoryNightResultProps) -> Html {
// match result {
// StoryActionResult::ShiftFailed => html!{
// <span class="story-text">{"but it failed"}</span>
// },
// StoryActionResult::Drunk => html! {
// <>
// <span class="story-text">{"but got "}</span>
// <AuraSpan aura={AuraTitle::Drunk}/>
// <span class="story-text">{" instead"}</span>
// </>
// },
// StoryActionResult::BeholderSawEverything => html!{
// <span class="story-text">{"and saw everything 👁️"}</span>
// },
// StoryActionResult::BeholderSawNothing => html!{
// <span class="story-text">{"but saw nothing"}</span>
// },
// StoryActionResult::RoleBlocked => html! {
// <span class="story-text">{"but was role blocked"}</span>
// },
// StoryActionResult::Seer(alignment) => {
// html! {
// <span>
// <span class="story-text">{"and saw"}</span>
// <AlignmentSpan alignment={*alignment}/>
// </span>
// }
// }
// StoryActionResult::PowerSeer { powerful } => {
// html! {
// <span>
// <span class="story-text">{"and discovered they are"}</span>
// <PowerfulSpan powerful={*powerful}/>
// </span>
// }
// }
// StoryActionResult::Adjudicator { killer } => html! {
// <span>
// <span class="story-text">{"and saw"}</span>
// <KillerSpan killer={*killer}/>
// </span>
// },
// StoryActionResult::Arcanist(same) => html! {
// <span>
// <span class="story-text">{"and saw"}</span>
// <AlignmentComparisonSpan comparison={*same}/>
// </span>
// },
// StoryActionResult::GraveDigger(None) => html! {
// <span class="story-text">
// {"found an empty grave"}
// </span>
// },
// StoryActionResult::GraveDigger(Some(role_title)) => {
// let category = Into::<SetupRole>::into(*role_title).category();
// html! {
// <span>
// <span class="story-text">{"found the body of a"}</span>
// <CategorySpan category={category} icon={role_title.icon()}>
// {role_title.to_string().to_case(Case::Title)}
// </CategorySpan>
// </span>
// }
// }
// StoryActionResult::Mortician(died_to_title) => html! {
// <>
// <span class="story-text">{"and found the cause of death to be"}</span>
// <DiedToSpan died_to={*died_to_title}/>
// </>
// },
// StoryActionResult::Insomniac { visits } => {
// let visitors = visits
// .iter()
// .filter_map(|c| characters.get(c))
// .map(|c| {
// html! {
// <CharacterCard faint=true char={c.clone()}/>
// }
// })
// .collect::<Html>();
// html! {
// {visitors}
// }
// }
// StoryActionResult::Empath { scapegoat: false } => html! {
// <>
// <span class="story-text">{"and saw that they are"}</span>
// <span class="attribute-span faint">
// <div class="inactive">
// <Icon source={IconSource::Heart} icon_type={IconType::Small}/>
// </div>
// {"Not The Scapegoat"}
// </span>
// </>
// },
// StoryActionResult::Empath { scapegoat: true } => html! {
// <>
// <span class="story-text">{"and saw that they are"}</span>
// <span class="attribute-span faint wolves">
// <div>
// <Icon source={IconSource::Heart} icon_type={IconType::Small}/>
// </div>
// {"The Scapegoat"}
// </span>
// </>
// },
// }
// }
// #[derive(Debug, Clone, PartialEq, Properties)]
// struct StoryNightChoiceProps {
// choice: NightChoice,
// characters: Rc<HashMap<CharacterId, Character>>,
// }
// #[function_component]
// fn StoryNightChoice(StoryNightChoiceProps { choice, characters}: &StoryNightChoiceProps) -> Html {
// let generate = |character_id: &CharacterId, chosen: &CharacterId, action: &'static str| {
// characters
// .get(character_id)
// .and_then(|char| characters.get(chosen).map(|c| (char, c)))
// .map(|(char, chosen)| {
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{action}</span>
// <CharacterCard faint=true char={chosen.clone()}/>
// </>
// }
// })
// };
// let choice_body = match &choice.prompt {
// StoryActionPrompt::Arcanist {
// character_id,
// chosen: (chosen1, chosen2),
// } => characters
// .get(character_id)
// .and_then(|arcanist| characters.get(chosen1).map(|c| (arcanist, c)))
// .and_then(|(arcanist, chosen1)| {
// characters
// .get(chosen2)
// .map(|chosen2| (arcanist, chosen1, chosen2))
// })
// .map(|(arcanist, chosen1, chosen2)| {
// html! {
// <>
// <CharacterCard faint=true char={arcanist.clone()}/>
// <span class="story-text">{"compared"}</span>
// <CharacterCard faint=true char={chosen1.clone()}/>
// <span class="story-text">{"and"}</span>
// <CharacterCard faint=true char={chosen2.clone()}/>
// </>
// }
// }),
// StoryActionPrompt::MasonsWake { leader, masons } => characters.get(leader).map(|leader| {
// let masons = masons
// .iter()
// .filter_map(|m| characters.get(m))
// .map(|c| {
// html! {
// <CharacterCard faint=true char={c.clone()}/>
// }
// })
// .collect::<Html>();
// html! {
// <>
// <CharacterCard faint=true char={leader.clone()}/>
// <span class="story-text">{"'s masons"}</span>
// {masons}
// <span class="story-text">{"convened in secret"}</span>
// </>
// }
// }),
// StoryActionPrompt::Bloodletter { character_id, chosen } => generate(character_id, chosen, "spilt wolf blood on"),
// StoryActionPrompt::BeholderWakes { character_id }=>characters
// .get(character_id)
// .map(|char| {
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{"woke up and saw"}</span>
// </>
// }
// }),
// StoryActionPrompt::Vindicator {
// character_id,
// chosen,
// }
// | StoryActionPrompt::Protector {
// character_id,
// chosen,
// } => generate(character_id, chosen, "protected"),
// StoryActionPrompt::Gravedigger {
// character_id,
// chosen,
// } => generate(character_id, chosen, "dug up"),
// StoryActionPrompt::Adjudicator {
// character_id,
// chosen,
// }
// | StoryActionPrompt::PowerSeer {
// character_id,
// chosen,
// }
// | StoryActionPrompt::Empath {
// character_id,
// chosen,
// }
// | StoryActionPrompt::Seer {
// character_id,
// chosen,
// } => generate(character_id, chosen, "checked"),
// StoryActionPrompt::Hunter {
// character_id,
// chosen,
// } => generate(character_id, chosen, "set a trap for"),
// StoryActionPrompt::Militia {
// character_id,
// chosen,
// } => generate(character_id, chosen, "shot"),
// StoryActionPrompt::MapleWolf {
// character_id,
// chosen,
// } => characters
// .get(character_id)
// .and_then(|char| characters.get(chosen).map(|c| (char, c)))
// .map(|(char, chosen)| {
// html! {
// <>
// <CharacterCard faint=true char={char.clone()}/>
// <span class="story-text">{"invited"}</span>
// <CharacterCard faint=true char={chosen.clone()}/>
// <span class="story-text">{"for dinner"}</span>
// </>
// }
// }),
// StoryActionPrompt::Guardian {
// character_id,
// chosen,
// guarding,
// } => generate(
// character_id,
// chosen,
// if *guarding { "guarded" } else { "protected" },
// ),
// StoryActionPrompt::Mortician {
// character_id,
// chosen,
// } => generate(character_id, chosen, "examined"),
// StoryActionPrompt::Beholder {
// character_id,
// chosen,
// } => generate(character_id, chosen, "👁️"),
// StoryActionPrompt::MasonLeaderRecruit {
// character_id,
// chosen,
// } => generate(character_id, chosen, "tried recruiting"),
// StoryActionPrompt::PyreMaster {
// character_id,
// chosen,
// } => generate(character_id, chosen, "torched"),
// StoryActionPrompt::WolfPackKill { chosen } => {
// characters.get(chosen).map(|chosen: &Character| {
// html! {
// <>
// <AlignmentSpan alignment={Alignment::Wolves}/>
// <span class="story-text">{"attempted a kill on"}</span>
// <CharacterCard faint=true char={chosen.clone()} />
// </>
// }
// })
// }
// StoryActionPrompt::Shapeshifter { character_id } => {
// if choice.result.is_none() {
// return html!{};
// }
// characters.get(character_id).map(|shifter| {
// html! {
// <>
// <CharacterCard faint=true char={shifter.clone()} />
// <span class="story-text">{"decided to shapeshift into the wolf kill target"}</span>
// </>
// }
// })
// }
// StoryActionPrompt::AlphaWolf {
// character_id,
// chosen,
// } => generate(character_id, chosen, "took a stab at"),
// StoryActionPrompt::DireWolf {
// character_id,
// chosen,
// } => generate(character_id, chosen, "roleblocked"),
// StoryActionPrompt::LoneWolfKill {
// character_id,
// chosen,
// } => generate(character_id, chosen, "sought vengeance from"),
// StoryActionPrompt::Insomniac { character_id } => {
// characters.get(character_id).map(|insomniac| {
// html! {
// <>
// <CharacterCard faint=true char={insomniac.clone()} />
// <span class="story-text">{"witnessed visits from"}</span>
// </>
// }
// })
// }
// };
// let result = choice.result.as_ref().map(|result| {
// html! {
// <StoryNightResult result={result.clone()} characters={characters.clone()}/>
// }
// });
// choice_body
// .map(|choice_body| {
// html! {
// <li class="choice">
// <Icon
// source={IconSource::ListItem}
// icon_type={IconType::Small}
// classes={classes!("li-icon")}
// />
// {choice_body}
// {result}
// </li>
// }
// })
// .unwrap_or_default()
// }