diff --git a/server/__init__.py b/server/__init__.py index 19583c77..6f970a4e 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,5 +1,7 @@ from .summon import Summons as OtherSummons -from .charactor import Charactors # noqa: F401 +from .charactor import ( # noqa: F401 + Charactors, SummonsOfCharactors, CharactorTalents +) from .status import CharactorStatus, TeamStatus # noqa: F401 from .card import Cards as OtherCards from .card.support import Supports # noqa: F401 @@ -7,5 +9,5 @@ # For summons and cards, some will implement in charactor files. # For status, it is impossible, so no need to collect from other folder. -Summons = OtherSummons | OtherSummons -Cards = OtherCards | OtherCards +Summons = OtherSummons | SummonsOfCharactors +Cards = OtherCards | CharactorTalents diff --git a/server/card/equipment/artifact/base.py b/server/card/equipment/artifact/base.py index 062d1840..f9c052c4 100644 --- a/server/card/equipment/artifact/base.py +++ b/server/card/equipment/artifact/base.py @@ -43,7 +43,7 @@ def get_actions( ) -> List[MoveObjectAction | RemoveObjectAction]: """ Act the artifact. will place it into artifact area. - When artifact is equipped, remove the old one. + When other artifact is equipped, remove the old one. """ assert target is not None ret: List[MoveObjectAction | RemoveObjectAction] = [] diff --git a/server/card/support/support_base.py b/server/card/support/support_base.py index c5908d9d..017c6e06 100644 --- a/server/card/support/support_base.py +++ b/server/card/support/support_base.py @@ -36,6 +36,12 @@ def check_remove_triggered(self) -> List[Actions]: )] return [] + def is_valid(self, match: Any) -> bool: + """ + If it is not in hand, cannot use. + """ + return self.position.area == ObjectPositionType.HAND + def act(self): """ when this support card is activated from hand, this function is called diff --git a/server/charactor/__init__.py b/server/charactor/__init__.py index 751c88c1..f7dfd47a 100644 --- a/server/charactor/__init__.py +++ b/server/charactor/__init__.py @@ -1,6 +1,11 @@ from .mob import Mob from .physical_mob import PhysicalMob from .mob_mage import MobMage +from .electro import ( + ElectroCharactorTalents, ElectroCharactors, SummonsOfElectroCharactors +) -Charactors = Mob | PhysicalMob | MobMage +Charactors = Mob | PhysicalMob | MobMage | ElectroCharactors +SummonsOfCharactors = SummonsOfElectroCharactors | SummonsOfElectroCharactors +CharactorTalents = ElectroCharactorTalents | ElectroCharactorTalents diff --git a/server/charactor/charactor_base.py b/server/charactor/charactor_base.py index 26ee7bac..ba9332c0 100644 --- a/server/charactor/charactor_base.py +++ b/server/charactor/charactor_base.py @@ -6,16 +6,90 @@ """ -from typing import List, Literal +from typing import List, Literal, Any from ..consts import ( - ObjectType, WeaponType, ElementType, FactionType, ObjectPositionType + ObjectType, WeaponType, ElementType, FactionType, ObjectPositionType, + DiceCostLabels ) from ..object_base import ( - ObjectBase, SkillBase, WeaponBase, TalentBase + ObjectBase, SkillBase, WeaponBase, CardBase ) -from ..struct import ObjectPosition +from ..struct import ObjectPosition, CardActionTarget from ..status import CharactorStatus from ..card.equipment.artifact import Artifacts +from ..action import MoveObjectAction, RemoveObjectAction, Actions + + +class TalentBase(CardBase): + """ + Base class of talents. Note almost all talents are skills, and will receive + cost decrease from other objects. + """ + name: str + charactor_name: str + type: Literal[ObjectType.TALENT] = ObjectType.TALENT + cost_label: int = DiceCostLabels.CARD.value | DiceCostLabels.TALENT.value + + def is_valid(self, match: Any) -> bool: + """ + Only corresponding charactor is active charactor can equip this card. + """ + if self.position.area != ObjectPositionType.HAND: + # not in hand, cannot equip + return False + table = match.player_tables[self.position.player_id] + return (table.charactors[table.active_charactor_id].name + == self.charactor_name) + + def get_targets(self, match: Any) -> List[CardActionTarget]: + """ + For most talent cards, can quip only on active charactor, so no need + to specify targets. + """ + return [] + + def get_actions( + self, target: CardActionTarget | None, match: Any + ) -> List[Actions]: + """ + Act the talent. will place it into talent area. + When other talent is equipped, remove the old one. + For subclasses, inherit this and add other actions (e.g. trigger + correcponding skills) + """ + assert target is None + ret: List[Actions] = [] + table = match.player_tables[self.position.player_id] + charactor = table.charactors[table.active_charactor_id] + # check if need to remove current talent + if charactor.talent is not None: + ret.append(RemoveObjectAction( + object_position = charactor.talent.position, + object_id = charactor.talent.id, + )) + ret.append(MoveObjectAction( + object_position = self.position, + object_id = self.id, + target_position = charactor.position.copy(deep = True), + )) + return ret + + +class SkillTalent(TalentBase): + """ + Talents that trigger skills. They will get skill as input, which is + saved as a private variable. + """ + + skill: SkillBase + + def get_actions( + self, target: CardActionTarget | None, match: Any + ) -> List[Actions]: + ret = super().get_actions(target, match) + self.skill.position = self.position + ret += self.skill.get_actions(match) + return ret class CharactorBase(ObjectBase): diff --git a/server/charactor/electro/__init__.py b/server/charactor/electro/__init__.py new file mode 100644 index 00000000..d939cf43 --- /dev/null +++ b/server/charactor/electro/__init__.py @@ -0,0 +1,6 @@ +from .fischl import Fischl, Oz, StellarPredator + + +ElectroCharactors = Fischl | Fischl +SummonsOfElectroCharactors = Oz | Oz +ElectroCharactorTalents = StellarPredator | StellarPredator diff --git a/server/charactor/electro/fischl.py b/server/charactor/electro/fischl.py new file mode 100644 index 00000000..52689024 --- /dev/null +++ b/server/charactor/electro/fischl.py @@ -0,0 +1,182 @@ +from typing import List, Literal, Any + +from server.action import MakeDamageAction +from ...event import SkillEndEventArguments + +from ...action import Actions, CreateObjectAction +from ...object_base import ( + PhysicalNormalAttackBase, + ElementalSkillBase, ElementalBurstBase +) +from ...consts import ( + ElementType, FactionType, SkillType, WeaponType, DamageElementalType, + ObjectPositionType, DamageSourceType, DamageType, DieColor +) +from ..charactor_base import CharactorBase, SkillTalent +from ...struct import DamageValue, DiceCost +from ...summon.base import AttackerSummonBase + + +class Nightrider(ElementalSkillBase): + name: Literal['Nightrider'] = 'Nightrider' + desc: str = 'Deals 1 Electro DMG, summons 1 Oz.' + element: ElementType = ElementType.ELECTRO + damage: int = 1 + damage_type: DamageElementalType = DamageElementalType.ELECTRO + cost: DiceCost = DiceCost( + elemental_dice_color = DieColor.ELECTRO, + elemental_dice_number = 3, + ) + + def get_actions(self, match: Any) -> List[Actions]: + position = self.position.copy(deep = True) + position.area = ObjectPositionType.SUMMON + return super().get_actions(match) + [ + CreateObjectAction( + object_name = 'Oz', + object_position = position, + object_arguments = {} + ) + ] + + +class MidnightPhantasmagoria(ElementalBurstBase): + name: Literal['Midnight Phantasmagoria'] = 'Midnight Phantasmagoria' + desc: str = ( + 'Deals 4 Electro DMG, deals 2 Piercing DMG to all opposing characters ' + 'on standby.' + ) + damage: int = 4 + damage_type: DamageElementalType = DamageElementalType.ELECTRO + charge: int = 3 + cost: DiceCost = DiceCost( + elemental_dice_color = DieColor.ELECTRO, + elemental_dice_number = 3, + ) + + def get_actions(self, match: Any) -> List[Actions]: + ret = super().get_actions(match) + assert len(ret) > 0 + assert ret[-1].type == 'MAKE_DAMAGE' + ret[-1].damage_value_list.append( + DamageValue( + position = self.position.copy(deep = True), + id = self.id, + damage_type = DamageType.DAMAGE, + damage_source_type = DamageSourceType.CURRENT_PLAYER_CHARACTOR, + damage = 2, + damage_elemental_type = DamageElementalType.PIERCING, + charge_cost = self.charge, + target_player = 'ENEMY', + target_charactor = 'BACK', + ) + ) + return ret + + +class StellarPredator(SkillTalent): + name: Literal['Stellar Predator'] + charactor_name: str = 'Fischl' + version: Literal['3.3'] = '3.3' + desc: str = ( + 'Combat Action: When your active character is Fischl, equip this card.' + 'After Fischl equips this card, immediately use Nightrider once.' + 'When your Fischl, who has this card equipped, creates an Oz, and ' + 'after Fischl uses a Normal Attack: Deal 2 Electro DMG. ' + '(Consumes Usage(s))' + ) + cost: DiceCost = DiceCost( + elemental_dice_color = DieColor.ELECTRO, + elemental_dice_number = 3, + ) + skill: Nightrider = Nightrider() + + +class Oz(AttackerSummonBase): + name: Literal['Oz'] + desc: str = '''End Phase: Deal 1 Electro DMG.''' + version: Literal['3.3'] = '3.3' + usage: int = 2 + max_usage: int = 2 + damage_elemental_type: DamageElementalType = DamageElementalType.ELECTRO + damage: int = 1 + renew_type: Literal['RESET_WITH_MAX'] = 'RESET_WITH_MAX' + + def event_handler_SKILL_END( + self, event: SkillEndEventArguments + ) -> list[MakeDamageAction]: + """ + If Fischl made normal attack and with talent, make 2 electro damage + to front. + """ + match = event.match + action = event.action + if action.skill_type != SkillType.NORMAL_ATTACK: + # not using normal attack + return [] + if self.position.player_id != action.player_id: + # not attack by self + return [] + charactor = match.player_tables[action.player_id].charactors[ + action.charactor_id + ] + if ( + charactor.talent is not None and charactor.name == 'Fischl' + and charactor.talent.name == 'Stellar Predator' + ): + # match, decrease usage, attack. + # after make damage, will trigger usage check, so no need to + # add RemoveObjectAction here. + assert self.usage > 0 + self.usage -= 1 + source_type = DamageSourceType.CURRENT_PLAYER_SUMMON + return [ + MakeDamageAction( + player_id = self.position.player_id, + target_id = 1 - self.position.player_id, + damage_value_list = [ + DamageValue( + position = self.position, + id = self.id, + damage_type = DamageType.DAMAGE, + damage_source_type = source_type, + damage = 2, + damage_elemental_type = self.damage_elemental_type, + charge_cost = 0, + target_player = 'ENEMY', + target_charactor = 'ACTIVE' + ) + ], + charactor_change_rule = 'NONE', + ) + ] + return [] + + +class Fischl(CharactorBase): + name: Literal['Fischl'] + version: Literal['3.3'] = '3.3' + desc: str = '''"Fischl, Prinzessin der Verurteilung!" Fischl''' + element: ElementType = ElementType.ELECTRO + hp: int = 10 + max_hp: int = 10 + charge: int = 0 + max_charge: int = 3 + skills: list[ + PhysicalNormalAttackBase | Nightrider | MidnightPhantasmagoria + ] = [] + faction: list[FactionType] = [ + FactionType.MONDSTADT, + ] + weapon_type: WeaponType = WeaponType.BOW + + def __init__(self, **kwargs): + super().__init__(**kwargs) # type: ignore + self.skills = [ + PhysicalNormalAttackBase( + name = 'Bolts of Downfall', + cost = PhysicalNormalAttackBase.get_cost(self.element), + ), + Nightrider(), + MidnightPhantasmagoria(), + ] diff --git a/server/match.py b/server/match.py index 9c62357f..145b8eff 100644 --- a/server/match.py +++ b/server/match.py @@ -1965,6 +1965,10 @@ def _action_move_object(self, action: MoveObjectAction) \ assert charactor.artifact is None charactor.artifact = current_object # type: ignore target_name = 'artifact' + elif current_object.type == ObjectType.TALENT: + assert charactor.talent is None + charactor.talent = current_object # type: ignore + target_name = 'talent' else: raise NotImplementedError( f'Move object action as eqipment with type ' diff --git a/server/object_base.py b/server/object_base.py index a539c4ab..60e76dad 100644 --- a/server/object_base.py +++ b/server/object_base.py @@ -156,7 +156,7 @@ class ElementalNormalAttackBase(SkillBase): """ Base class of elemental normal attacks. """ - desc: str = """Deals 1 XXX DMG.""" + desc: str = """Deals 1 _ELEMENT_ DMG.""" skill_type: Literal[SkillType.NORMAL_ATTACK] = SkillType.NORMAL_ATTACK damage_type: DamageElementalType damage: int = 1 @@ -165,7 +165,7 @@ class ElementalNormalAttackBase(SkillBase): def __init__(self, *argv, **kwargs): super().__init__(*argv, **kwargs) self.desc = self.desc.replace( - 'XXX', self.damage_type.value.lower().capitalize()) + '_ELEMENT_', self.damage_type.value.lower().capitalize()) @staticmethod def get_cost(element: ElementType) -> DiceCost: @@ -180,7 +180,7 @@ class ElementalSkillBase(SkillBase): """ Base class of elemental skills. """ - desc: str = """Deals 3 XXX DMG.""" + desc: str = """Deals 3 _ELEMENT_ DMG.""" skill_type: Literal[SkillType.ELEMENTAL_SKILL] = SkillType.ELEMENTAL_SKILL damage_type: DamageElementalType damage: int = 3 @@ -189,7 +189,7 @@ class ElementalSkillBase(SkillBase): def __init__(self, *argv, **kwargs): super().__init__(*argv, **kwargs) self.desc = self.desc.replace( - 'XXX', self.damage_type.value.lower().capitalize()) + '_ELEMENT_', self.damage_type.value.lower().capitalize()) @staticmethod def get_cost(element: ElementType) -> DiceCost: @@ -203,7 +203,7 @@ class ElementalBurstBase(SkillBase): """ Base class of elemental bursts. """ - desc: str = """Deals %d XXX DMG.""" + desc: str = """Deals _DAMAME_ _ELEMENT_ DMG.""" skill_type: Literal[SkillType.ELEMENTAL_BURST] = SkillType.ELEMENTAL_BURST damage_type: DamageElementalType charge: int @@ -219,8 +219,8 @@ def get_cost(element: ElementType, number: int) -> DiceCost: def __init__(self, *argv, **kwargs): super().__init__(*argv, **kwargs) self.desc = self.desc.replace( - 'XXX', self.damage_type.value.lower().capitalize()) - self.desc = self.desc.replace('%d', str(self.damage)) + '_ELEMENT_', self.damage_type.value.lower().capitalize()) + self.desc = self.desc.replace('_DAMAGE_', str(self.damage)) def is_valid(self, match: Any) -> bool: """ @@ -309,13 +309,3 @@ class WeaponBase(CardBase): type: Literal[ObjectType.WEAPON] = ObjectType.WEAPON cost_label: int = DiceCostLabels.CARD.value | DiceCostLabels.WEAPON.value weapon_type: WeaponType - - -class TalentBase(CardBase): - """ - Base class of talents. Note almost all talents are skills, and will receive - cost decrease from other objects. - """ - name: str - type: Literal[ObjectType.TALENT] = ObjectType.TALENT - cost_label: int = DiceCostLabels.CARD.value | DiceCostLabels.TALENT.value diff --git a/server/summon/base.py b/server/summon/base.py index 4ca51a1f..0136b748 100644 --- a/server/summon/base.py +++ b/server/summon/base.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, Any from ..object_base import CardBase from ..consts import ( ObjectType, DamageElementalType, DamageSourceType, DamageType @@ -35,6 +35,12 @@ def renew(self, new_status: 'SummonBase') -> None: elif self.renew_type == 'RESET_WITH_MAX': self.usage = max(self.usage, new_status.usage) + def is_valid(self, match: Any) -> bool: + """ + For summons, it is expected to be always invalid to use as card. + """ + return False + class AttackerSummonBase(SummonBase):