import * as Chance from 'chance' ; import { sortBy , size , cloneDeep } from 'lodash' ; import { Subject } from 'rxjs' ; import { ProfessionSkillMap , AttributeSkillMap , AffinitySkillMap , Attack , ProfessionPreRoundSkillMap , ProfessionPostRoundSkillMap } from './skillgroups' ; import { PartialCombatSkill , ICombatCharacter , ICombat , ICombatSkillCombinator , Stat , ICombatSkillEffect , SkillCombinatorFunction , ICombatWeightedSkillChoice } from '../interfaces' ; export enum CombatAction { PrintStatistics , Message , SummaryMessage , Victory , IncrementStatistic } export class CombatSimulator { public get events$ ( ) { return this . events ; } constructor ( private combat ) { this . combat = cloneDeep ( this . combat ) ; if ( ! this . combat . seed ) this . combat . seed = Date . now ( ) ; if ( ! this . combat . currentRound ) this . combat . currentRound = 0 ; if ( ! this . combat . timestamp ) this . combat . timestamp = Date . now ( ) ; this . chance = new Chance ( this . combat . seed ) ; this . combat . chance = this . chance ; } private chance : Chance ; private events : Subject < { action : CombatAction ; data : any ; } > = new Subject < { action : CombatAction ; data : any ; } > ( ) ; private formSkillResult ( caster , combinators ) { const baseSkill = { } ; return combinators . reduce ( ( prev , cur ) => { return cur ( prev , caster , this . combat ) ; } , baseSkill ) ; } private formAllSkillResults ( caster , allCombinators ) { let allSkills = allCombinators ; if ( allSkills instanceof Function ) { allSkills = ( allCombinators as SkillCombinatorFunction ) ( this . combat , caster ) ; } return allSkills . map ( x => this . formSkillResult ( caster , x ) ) ; } private emitAction ( action ) { this . events . next ( action ) ; } private formatCombat ( combat ) { const res = cloneDeep ( combat ) ; delete res . chance ; return res ; } private getPreRoundSkillsForCharacter ( character ) { return ( ProfessionPreRoundSkillMap [ character . profession ] || [ ] ) . filter ( x => x . canUse ? x . canUse ( character , this . combat ) : true ) ; } private getPostRoundSkillsForCharacter ( character ) { return ( ProfessionPostRoundSkillMap [ character . profession ] || [ ] ) . filter ( x => x . canUse ? x . canUse ( character , this . combat ) : true ) ; } private getSkillsForCharacter ( character ) { const arr = [ ] ; if ( ProfessionSkillMap [ character . profession ] ) arr . push ( ... ProfessionSkillMap [ character . profession ] ) ; if ( AffinitySkillMap [ character . affinity ] ) arr . push ( ... AffinitySkillMap [ character . affinity ] ) ; if ( AttributeSkillMap [ character . attribute ] && character . rating >= 5 ) arr . push ( ... AttributeSkillMap [ character . attribute ] ) ; if ( arr . length === 0 ) { arr . push ( { weight : 1 , skills : [ Attack ( ) ] } ) ; } return arr . filter ( x => x . canUse ? x . canUse ( character , this . combat ) : true ) ; } private isDead ( character ) { return character . stats [ Stat . HP ] <= 0 ; } private addCombatEffect ( character , effect ) { const delay = effect . turnsUntilEffect || 0 ; const duration = effect . turnsEffectLasts || 0 ; character . effects = character . effects || [ ] ; let turn = delay ; do { character . effects [ turn ] = character . effects [ turn ] || [ ] ; character . effects [ turn ] . push ( effect ) ; turn ++ ; } while ( turn < delay + duration ) ; } private doSkill ( caster , skill ) { const effects = this . formAllSkillResults ( caster , skill ) ; effects . forEach ( effect => { effect . targets . forEach ( target => { if ( isNaN ( target ) ) return ; effect . targetEffects [ target ] . forEach ( targetEffect => { if ( targetEffect . immediate ) { this . applySingleEffect ( this . combat . characters [ target ] , targetEffect ) ; return ; } this . addCombatEffect ( this . combat . characters [ target ] , targetEffect ) ; } ) ; } ) ; } ) ; } private doSkillImmediately ( caster , skill ) { const effects = this . formAllSkillResults ( caster , skill ) ; effects . forEach ( effect => { effect . targets . forEach ( target => { if ( isNaN ( target ) ) return ; effect . targetEffects [ target ] . forEach ( targetEffect => { this . applySingleEffect ( this . combat . characters [ target ] , targetEffect ) ; } ) ; } ) ; } ) ; } private formatMessage ( skillEffect , forCharacter ) { if ( ! skillEffect . desc ) return '' ; const refChar = this . combat . characters [ skillEffect . source ] ; let absDamage = Math . abs ( skillEffect . modifyStatValue ) ; if ( ! isFinite ( absDamage ) || isNaN ( absDamage ) ) absDamage = 0 ; const damageString = absDamage === 0 ? `0 [miss]` : absDamage . toLocaleString ( ) ; const replacements = [ { replace : 'source' , with : refChar ? refChar . name : 'a mysterious force' } , { replace : 'value' , with : damageString } , { replace : 'rvalue' , with : skillEffect . modifyStatValue . toLocaleString ( ) } , { replace : 'target' , with : forCharacter . name } , { replace : 'special' , with : forCharacter . specialName || 'special' } ] ; return replacements . reduce ( ( prev , cur ) => { return prev . split ( `%${ cur . replace }` ) . join ( cur . with ) ; } , skillEffect . desc ) ; } private applySingleEffect ( character , effect ) { const oldHPValue = character . stats [ Stat . HP ] ; if ( effect . modifyStat ) { if ( ! this . chance . bool ( { likelihood : Math . max ( 0 , Math . min ( 100 , effect . accuracy ) ) } ) || isNaN ( effect . modifyStatValue ) ) { effect . modifyStatValue = 0 ; } effect . modifyStatValue = Math . floor ( effect . modifyStatValue ) ; if ( isNaN ( effect . modifyStatValue ) || ! isFinite ( effect . modifyStatValue ) ) effect . modifyStatValue = 0 ; if ( effect . modifyStat === Stat . HP ) { if ( effect . modifyStatValue + character . stats [ Stat . HP ] > character . maxStats [ Stat . HP ] ) { effect . modifyStatValue = character . maxStats [ Stat . HP ] - character . stats [ Stat . HP ] ; } if ( effect . modifyStatValue + character . stats [ Stat . HP ] <= 0 ) { effect . modifyStatValue = - character . stats [ Stat . HP ] ; } } if ( effect . modifyStat === Stat . SPECIAL ) { if ( effect . modifyStatValue + character . stats [ Stat . SPECIAL ] > character . maxStats [ Stat . SPECIAL ] ) { effect . modifyStatValue = character . maxStats [ Stat . SPECIAL ] - character . stats [ Stat . SPECIAL ] ; } } character . stats [ effect . modifyStat ] += effect . modifyStatValue ; } const message = this . formatMessage ( effect , character ) ; this . events$ . next ( { action : CombatAction . Message , data : { message , source : effect . source , combat : this . formatCombat ( this . combat ) } } ) ; if ( effect . modifyStat === Stat . HP && oldHPValue !== 0 ) { const giver = this . combat . characters [ effect . source ] ; const type = effect . modifyStatValue === 0 ? 'Miss' : ( effect . modifyStatValue < 0 ? 'Damage' : 'Healing' ) ; const incrementValue = effect . modifyStatValue === 0 ? 1 : Math . abs ( effect . modifyStatValue ) ; this . incrementStatistic ( giver , `Combat/All/Give/Attack/Times` , 1 ) ; this . incrementStatistic ( giver , `Combat/All/Give/${ type }` , incrementValue ) ; this . incrementStatistic ( character , `Combat/All/Receive/Attack/Times` , 1 ) ; this . incrementStatistic ( character , `Combat/All/Receive/${ type }` , incrementValue ) ; if ( character . stats [ Stat . HP ] <= 0 ) { character . stats [ Stat . HP ] = 0 ; character . effects = [ ] ; this . incrementStatistic ( giver , `Combat/All/Kill/${ character . realName ? 'Player' : 'Monster' }` ) ; } } } private applyNextEffects ( character ) { if ( ! character . effects ) return ; const effects = character . effects . shift ( ) ; if ( ! effects || ! effects . length ) return ; effects . forEach ( effect => { this . applySingleEffect ( character , effect ) ; } ) ; } private cleanUpDeadSummons ( ) { Object . keys ( this . combat . characters ) . forEach ( combatId => { const check = this . combat . characters [ combatId ] ; if ( ! check . summonerId ) return ; if ( check . stats [ Stat . HP ] > 0 ) return ; delete this . combat . characters [ combatId ] ; } ) ; } beginCombat ( ) { this . emitAction ( { action : CombatAction . PrintStatistics , data : this . formatCombat ( this . combat ) } ) ; this . beginRound ( ) ; } beginRound ( ) { this . combat . currentRound ++ ; const validCombatants = Object . values ( this . combat . characters ) ; const combatantOrder = sortBy ( validCombatants , ( char ) => char . stats [ Stat . AGI ] ) ; combatantOrder . filter ( x => x . stats [ Stat . HP ] > 0 ) . forEach ( comb => { const preroundSkills = this . getPreRoundSkillsForCharacter ( comb ) ; if ( preroundSkills . length > 0 ) { preroundSkills . forEach ( ( { skills } ) => { this . doSkillImmediately ( comb , skills ) ; } ) ; } const validSkills = this . getSkillsForCharacter ( comb ) ; const chosenSkill = this . chance . weighted ( validSkills . map ( x => x . skills ) , validSkills . map ( x => x . weight ) ) ; this . doSkill ( comb , chosenSkill ) ; } ) ; combatantOrder . forEach ( comb => { this . applyNextEffects ( comb ) ; } ) ; this . cleanUpDeadSummons ( ) ; combatantOrder . filter ( x => x . stats [ Stat . HP ] > 0 ) . forEach ( comb => { const postroundSkills = this . getPostRoundSkillsForCharacter ( comb ) ; if ( postroundSkills . length > 0 ) { postroundSkills . forEach ( ( { skills } ) => { this . doSkillImmediately ( comb , skills ) ; } ) ; } } ) ; this . endRound ( ) ; } endRound ( ) { this . emitAction ( { action : CombatAction . PrintStatistics , data : this . formatCombat ( this . combat ) } ) ; const livingParties = { } ; Object . values ( this . combat . characters ) . forEach ( char => { if ( this . isDead ( char ) ) return ; livingParties [ char . combatPartyId ] = true ; } ) ; if ( size ( livingParties ) === 0 ) return this . endCombat ( { wasTie : true } ) ; if ( size ( livingParties ) === 1 ) return this . endCombat ( { winningParty : + Object . keys ( livingParties ) [ 0 ] } ) ; if ( this . combat . currentRound >= 100 ) return this . endCombat ( { wasTie : true } ) ; this . beginRound ( ) ; } endCombat ( args = { } ) { Object . values ( this . combat . characters ) . forEach ( char => { const didWin = char . combatPartyId === args . winningParty ; const combatType = args . wasTie ? 'Tie' : ( didWin ? 'Win' : 'Lose' ) ; this . incrementStatistic ( char , `Combat/All/Times/Total` ) ; this . incrementStatistic ( char , `Combat/All/Times/${ combatType }` ) ; } ) ; if ( args . wasTie ) { this . addSummaryMessage ( `It was a draw! No winners! No rewards!` ) ; this . events$ . next ( { action : CombatAction . Victory , data : { wasTie : true , combat : this . formatCombat ( this . combat ) } } ) ; return ; } const winningPlayers = Object . values ( this . combat . characters ) . filter ( char => char . combatPartyId === args . winningParty ) ; const winningParty = this . combat . parties [ args . winningParty ] ; this . addSummaryMessage ( `${ winningParty . name } (${ winningPlayers . map ( char => char . name ) . join ( ', ' ) }) have won the battle!` ) ; this . events$ . next ( { action : CombatAction . Victory , data : { wasTie : args . wasTie , combat : this . formatCombat ( this . combat ) , winningParty : args . winningParty } } ) ; } addSummaryMessage ( message ) { this . events$ . next ( { action : CombatAction . SummaryMessage , data : message } ) ; } incrementStatistic ( char , statistic , value = 1 ) { if ( isNaN ( value ) || ! isFinite ( value ) ) { console . log ( `char` , char . name , statistic , value , JSON . stringify ( this . combat ) ) ; } if ( ! char || ! char . realName || isNaN ( value ) || ! isFinite ( value ) ) return ; this . events$ . next ( { action : CombatAction . IncrementStatistic , data : { statistic , value , name : char . realName } } ) ; this . events$ . next ( { action : CombatAction . IncrementStatistic , data : { statistic : statistic . split ( 'All' ) . join ( `Profession/${ char . profession }` ) , value , name : char . realName } } ) ; } }