development 597bd7bbc680 cached
354 files
1.8 MB
508.0k tokens
772 symbols
1 requests
Download .txt
Showing preview only (2,000K chars total). Download the full file or copy to clipboard to get everything.
Repository: cynicaloptimist/improved-initiative
Branch: development
Commit: 597bd7bbc680
Files: 354
Total size: 1.8 MB

Directory structure:
gitextract_ukmc1mmn/

├── .dockerignore
├── .eslintignore
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── main.yml
│       └── node.js.yml
├── .gitignore
├── .nvmrc
├── .prettierrc.json
├── .travis.yml
├── .vscode/
│   └── launch.json
├── CONTRIBUTING.md
├── Dockerfile
├── Gruntfile.js
├── PRIVACY.md
├── Procfile
├── README.md
├── _config.yml
├── about.md
├── babel.config.json
├── client/
│   ├── .baseDir.ts
│   ├── .eslintrc.json
│   ├── Account/
│   │   ├── Account.ts
│   │   ├── AccountClient.test.ts
│   │   └── AccountClient.ts
│   ├── App.tsx
│   ├── AutosavedEncounterTest.test.tsx
│   ├── CombatFooter/
│   │   └── CombatFooter.tsx
│   ├── Combatant/
│   │   ├── Combatant.test.ts
│   │   ├── Combatant.ts
│   │   ├── CombatantDetails.tsx
│   │   ├── CombatantViewModel.ts
│   │   ├── GetOrRollMaximumHP.test.ts
│   │   ├── GetOrRollMaximumHP.ts
│   │   ├── IndexLabeling.test.ts
│   │   ├── MultipleCombatantDetails.tsx
│   │   ├── Tag.ts
│   │   ├── ToPlayerViewCombatantState.ts
│   │   └── linkComponentToObservables.tsx
│   ├── Commands/
│   │   ├── BuildCombatantCommandList.ts
│   │   ├── BuildEncounterCommandList.ts
│   │   ├── CombatantCommander.test.ts
│   │   ├── CombatantCommander.tsx
│   │   ├── Command.test.ts
│   │   ├── Command.ts
│   │   ├── CommandButton.tsx
│   │   ├── DefaultKeybindings.ts
│   │   ├── EncounterCommander.test.ts
│   │   ├── EncounterCommander.ts
│   │   ├── GetLegacyKeyBinding.ts
│   │   ├── LibrariesCommander.ts
│   │   ├── PromptQueue.test.ts
│   │   ├── PromptQueue.ts
│   │   ├── ToggleFullscreen.ts
│   │   ├── Toolbar.test.tsx
│   │   └── Toolbar.tsx
│   ├── Components/
│   │   ├── Button.tsx
│   │   ├── ErrorBoundary.tsx
│   │   ├── Info.tsx
│   │   ├── LoadingIndicator.tsx
│   │   ├── Overlay.tsx
│   │   ├── StatBlock.test.tsx
│   │   ├── StatBlock.tsx
│   │   ├── StatBlockHeader.tsx
│   │   └── Tabs.tsx
│   ├── Encounter/
│   │   ├── AutoPopulatedNotes.ts
│   │   ├── AutoRerollInitiativeOption.test.ts
│   │   ├── Encounter.test.ts
│   │   ├── Encounter.ts
│   │   ├── EncounterFlow.ts
│   │   ├── LegacyEncounter.test.ts
│   │   └── UpdateLegacySavedEncounter.ts
│   ├── Environment.ts
│   ├── GetContextualCommandSuggestion.tsx
│   ├── Importers/
│   │   ├── DnDAppFilesImporter.ts
│   │   ├── Importer.ts
│   │   ├── Open5eImporter.ts
│   │   ├── SpellImporter.test.ts
│   │   ├── SpellImporter.ts
│   │   ├── StatBlockImporter.test.ts
│   │   └── StatBlockImporter.ts
│   ├── Index.ts
│   ├── InitiativeList/
│   │   ├── CombatantRow.tsx
│   │   ├── CommandContext.tsx
│   │   ├── InitiativeList.test.tsx
│   │   ├── InitiativeList.tsx
│   │   ├── InitiativeListHeader.tsx
│   │   ├── InitiativeListHost.tsx
│   │   ├── RestoreCombatants.tsx
│   │   └── Tags.tsx
│   ├── LauncherViewModel.ts
│   ├── Layout/
│   │   ├── BannerHost.tsx
│   │   ├── CenterColumn.tsx
│   │   ├── LeftColumn.tsx
│   │   ├── RightColumn.tsx
│   │   ├── SelectedCombatants.tsx
│   │   ├── ThreeColumnLayout.tsx
│   │   ├── ToolbarHost.tsx
│   │   ├── VerticalResizer.tsx
│   │   ├── centerColumnView.tsx
│   │   └── interfacePriorityClass.tsx
│   ├── Library/
│   │   ├── Components/
│   │   │   ├── BuildListingTree.test.tsx
│   │   │   ├── BuildListingTree.tsx
│   │   │   ├── Folder.tsx
│   │   │   ├── LibraryFilter.tsx
│   │   │   ├── ListingButton.tsx
│   │   │   ├── ListingRow.tsx
│   │   │   ├── PaneHeader.tsx
│   │   │   └── SpellDetails.tsx
│   │   ├── FilterCache.test.ts
│   │   ├── FilterCache.ts
│   │   ├── Libraries.ts
│   │   ├── Listing.ts
│   │   ├── Manager/
│   │   │   ├── ActiveLibrary.tsx
│   │   │   ├── DeletePrompt.tsx
│   │   │   ├── EditorView.tsx
│   │   │   ├── LibraryManager.tsx
│   │   │   ├── LibraryManagerRow.tsx
│   │   │   ├── LibraryManagerToolbar.tsx
│   │   │   ├── ListingSelectionContext.ts
│   │   │   ├── MovePrompt.tsx
│   │   │   ├── SelectedItemsManager.tsx
│   │   │   ├── SelectedItemsView.tsx
│   │   │   ├── SelectedItemsViewForActiveTab.tsx
│   │   │   └── useSelection.ts
│   │   ├── ReferencePane/
│   │   │   ├── EncounterLibraryReferencePane.tsx
│   │   │   ├── LibraryReferencePane.tsx
│   │   │   ├── LibraryReferencePanes.tsx
│   │   │   ├── PersistentCharacterLibraryReferencePane.tsx
│   │   │   ├── SpellLibraryReferencePane.tsx
│   │   │   └── StatBlockLibraryReferencePane.tsx
│   │   ├── StatBlockLibrary.test.tsx
│   │   └── useLibrary.ts
│   ├── MockAccountClient.tsx
│   ├── PersistentCharacter/
│   │   └── PersistentCharacter.test.tsx
│   ├── PlayerView/
│   │   ├── CSSFrom.ts
│   │   ├── PlayerView.test.tsx
│   │   ├── PlayerViewClient.ts
│   │   ├── PlayerViewCombatantState.test.tsx
│   │   ├── PlayerViewEncounterState.test.tsx
│   │   ├── ReactPlayerView.tsx
│   │   ├── TurnTimer.test.tsx
│   │   └── components/
│   │       ├── CombatFooter.tsx
│   │       ├── CombatStatsPopup.tsx
│   │       ├── CustomStyles.tsx
│   │       ├── DamageSuggestor.tsx
│   │       ├── PlayerView.tsx
│   │       ├── PlayerViewCombatant.tsx
│   │       ├── PlayerViewCombatantHeader.tsx
│   │       ├── PortraitModal.tsx
│   │       ├── SpentReactionIndicator.tsx
│   │       └── TagSuggestor.tsx
│   ├── Prompts/
│   │   ├── AcceptDamagePrompt.tsx
│   │   ├── AcceptTagPrompt.tsx
│   │   ├── ApplyDamagePrompt.tsx
│   │   ├── ApplyHealingPrompt.tsx
│   │   ├── ApplyTemporaryHPPrompt.tsx
│   │   ├── CombatStatsPrompt.tsx
│   │   ├── ConcentrationPrompt.tsx
│   │   ├── ConditionReferencePrompt.tsx
│   │   ├── EditAliasPrompt.tsx
│   │   ├── EditInitiativePrompt.tsx
│   │   ├── InitiativePrompt.tsx
│   │   ├── LinkInitiativePrompt.tsx
│   │   ├── MoveEncounterPrompt.tsx
│   │   ├── PendingPrompts.tsx
│   │   ├── PlayerViewPrompt.tsx
│   │   ├── PrivacyPolicyPrompt.tsx
│   │   ├── QuickAddPrompt.tsx
│   │   ├── QuickEditStatBlockPrompt.tsx
│   │   ├── RollDicePrompt.tsx
│   │   ├── SaveEncounterPrompt.tsx
│   │   ├── SpellPrompt.tsx
│   │   ├── StandardPromptLayout.tsx
│   │   ├── TagPrompt.tsx
│   │   └── UpdateNotesPrompt.tsx
│   ├── Reducers/
│   │   ├── Actions.ts
│   │   ├── CombatantActions.tsx
│   │   ├── CombatantsReducer.test.tsx
│   │   ├── CombatantsReducer.tsx
│   │   ├── EncounterActions.tsx
│   │   ├── EncounterReducer.test.tsx
│   │   ├── EncounterReducer.tsx
│   │   ├── GetCombatantsSorted.tsx
│   │   └── InitializeCombatantFromStatBlock.tsx
│   ├── Rules/
│   │   ├── Conditions.ts
│   │   ├── Dice.ts
│   │   ├── RollResult.ts
│   │   ├── RollResults.test.ts
│   │   ├── Rules.test.ts
│   │   └── Rules.ts
│   ├── Settings/
│   │   ├── Settings.test.ts
│   │   ├── Settings.ts
│   │   ├── SettingsContext.ts
│   │   ├── Tips.ts
│   │   └── components/
│   │       ├── About.tsx
│   │       ├── AccountSettings.tsx
│   │       ├── AccountSyncSettings.tsx
│   │       ├── ColorBlock.tsx
│   │       ├── CommandInfo.ts
│   │       ├── CommandsSettings.tsx
│   │       ├── ContentSettings.tsx
│   │       ├── DisplaysToggle.tsx
│   │       ├── Dropdown.tsx
│   │       ├── EpicInitiativeSettings.tsx
│   │       ├── FileUploadButton.tsx
│   │       ├── LocalDataSettings.tsx
│   │       ├── OptionsSettings.tsx
│   │       ├── SettingsPane.tsx
│   │       ├── StatBlockCustomFields.tsx
│   │       ├── StylesChooser.tsx
│   │       ├── TipCarousel.tsx
│   │       └── Toggle.tsx
│   ├── StatBlockEditor/
│   │   ├── ConvertStringsToNumbersWhereNeeded.tsx
│   │   ├── EnumToggle.tsx
│   │   ├── SavedEncounterEditor.tsx
│   │   ├── SpellEditor.tsx
│   │   ├── StatBlockEditor.test.tsx
│   │   ├── StatBlockEditor.tsx
│   │   └── components/
│   │       ├── AutoHideField.tsx
│   │       ├── AutocompleteTextInput.tsx
│   │       ├── IdentityFields.tsx
│   │       ├── KeywordField.tsx
│   │       ├── NameAndModifierField.tsx
│   │       ├── PowerField.tsx
│   │       ├── SortableList.tsx
│   │       ├── StatBlockEditorFields.tsx
│   │       ├── TextField.tsx
│   │       ├── UseDragDrop.tsx
│   │       └── useFocus.ts
│   ├── TextEnricher/
│   │   ├── Counter.tsx
│   │   ├── TextEnricher.test.tsx
│   │   └── TextEnricher.tsx
│   ├── TrackerViewModel.tsx
│   ├── Tutorial/
│   │   ├── NotifyTutorialOfAction.ts
│   │   ├── Tutorial.tsx
│   │   └── TutorialSteps.ts
│   ├── Utility/
│   │   ├── CustomBindingHandlers.ts
│   │   ├── GetAlphaSortableLevelString.ts
│   │   ├── LegacySynchronousLocalStore.ts
│   │   ├── Metrics.ts
│   │   ├── RemovableArrayValue.ts
│   │   ├── Store.test.ts
│   │   ├── Store.ts
│   │   ├── TextAssets.ts
│   │   ├── TransferLocalStorage.ts
│   │   ├── useAsyncListing.tsx
│   │   ├── useRequest.ts
│   │   └── useStoreBackedState.ts
│   ├── Widgets/
│   │   ├── CombatTimer.ts
│   │   ├── DifficultyCalculator.test.ts
│   │   ├── DifficultyCalculator.ts
│   │   ├── EventLog.ts
│   │   └── GetTimerReadout.ts
│   ├── jest.config.js
│   ├── test/
│   │   ├── InitializeTestSettings.ts
│   │   ├── adapterSetup.ts
│   │   ├── buildEncounter.ts
│   │   └── mocksSetup.ts
│   ├── tsconfig.eslint.json
│   └── tsconfig.json
├── common/
│   ├── ClientEnvironment.ts
│   ├── CombatStats.ts
│   ├── CombatantState.ts
│   ├── CommandSetting.ts
│   ├── DurationTiming.ts
│   ├── EncounterState.ts
│   ├── Listable.ts
│   ├── PatreonPost.ts
│   ├── PersistentCharacter.ts
│   ├── PlayerViewCombatantState.ts
│   ├── PlayerViewSettings.ts
│   ├── PlayerViewState.ts
│   ├── SavedEncounter.ts
│   ├── Settings.ts
│   ├── Spell.ts
│   ├── StatBlock.ts
│   ├── Toolbox.ts
│   ├── ValidateEncounterId.ts
│   └── jest.config.js
├── html/
│   ├── landing.html
│   ├── playerview.html
│   ├── tracker.html
│   └── transferlocalstorage.html
├── lesscss/
│   ├── base/
│   │   ├── colors.less
│   │   ├── responsive.less
│   │   └── typography.less
│   ├── components/
│   │   ├── buttons.less
│   │   ├── cards.less
│   │   ├── combat-footer.less
│   │   ├── combatants.less
│   │   ├── libraries.less
│   │   ├── library-manager.less
│   │   ├── listing.less
│   │   ├── overlay.less
│   │   ├── prompts.less
│   │   ├── settings.less
│   │   ├── spell-editor.less
│   │   ├── spell.less
│   │   ├── statblock-editor.less
│   │   ├── statblock.less
│   │   ├── styles-chooser.less
│   │   ├── tabs.less
│   │   ├── toolbar.less
│   │   └── tutorial.less
│   ├── improved-initiative.less
│   ├── layout/
│   │   ├── base.less
│   │   └── forms.less
│   ├── pages/
│   │   ├── landing.less
│   │   ├── player-view.less
│   │   └── tracker.less
│   └── utilities/
│       ├── animations.less
│       └── helpers.less
├── license
├── ogl_creatures.json
├── ogl_spells.json
├── package.json
├── public/
│   ├── .well-known/
│   │   └── assetlinks.json
│   ├── BingSiteAuth.xml
│   ├── manifest.json
│   ├── robots.txt
│   └── sample_players.json
├── server/
│   ├── .baseDir.ts
│   ├── InMemoryPlayerViewManager.ts
│   ├── RedisPlayerViewManager.ts
│   ├── api_response_declined_pledge.json
│   ├── api_response_epic_account.json
│   ├── api_response_no_pledge.json
│   ├── configureAffiliateRoutes.ts
│   ├── configureBasicRulesContent.ts
│   ├── configureImportRoutes.ts
│   ├── configureOpen5eContent.ts
│   ├── dbconnection.test.ts
│   ├── dbconnection.ts
│   ├── getDbConnectionString.ts
│   ├── jest.config.js
│   ├── library.ts
│   ├── metrics.ts
│   ├── patreon.ts
│   ├── playerviewmanager.test.ts
│   ├── playerviewmanager.ts
│   ├── routes.ts
│   ├── server.ts
│   ├── session.ts
│   ├── sockets.ts
│   ├── storageroutes.ts
│   ├── tsconfig.json
│   └── user.ts
├── test-post.html
├── test_cases.txt
├── thanks.ts
├── web.config
├── webpack.config.base.js
├── webpack.config.dev.js
└── webpack.config.prod.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
node_modules/*
typings/*
public/user/*
public/fonts/*
*.log
*.js.map
*.js
*.css
.tscache
.vscode
.baseDir
!Gruntfile.js
!*.config.js
!*.config.base.js
!*.config.dev.js
!*.config.prod.js
!test/test.js


================================================
FILE: .eslintignore
================================================
server/*.test.ts

================================================
FILE: .eslintrc.json
================================================
{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint", "jest"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:jest/recommended"
  ],
  "overrides": [
    {
      "files": ["server/**/*.ts", "server/**/**.tsx"],
      "parserOptions": {
        "project": "./server/tsconfig.json"
      },
      "env": {
        "node": true
      }
    }
  ],
  "rules": {
    "@typescript-eslint/camelcase": "off",
    "@typescript-eslint/explicit-function-return-type": "off",
    "@typescript-eslint/explicit-member-accessibility": "off",
    "@typescript-eslint/indent": "off",
    "@typescript-eslint/interface-name-prefix": "off",
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/no-empty-interface": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-inferrable-types": "error",
    "@typescript-eslint/no-namespace": "off",
    "@typescript-eslint/no-unused-vars": "off",
    "@typescript-eslint/no-use-before-define": "off",
    "@typescript-eslint/no-var-requires": "off",
    "@typescript-eslint/prefer-namespace-keyword": "error",
    "indent": "off",
    "no-async-promise-executor": "off",
    "no-empty": "off",
    "no-inner-declarations": "off",
    "no-undef": "off",
    "no-var": "error",
    "no-unused-expressions": "error"
  }
}


================================================
FILE: .github/workflows/main.yml
================================================
name: Bump Patch Version on development branch

on:
  push:
    branches: [master]

jobs:
  update-version:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
        with:
          ref: "development"

      - name: Bump patch version and git push
        run: |
          git config --global user.email "action@github.com"
          git config --global user.name "GitHub Action"
          npm version patch
          git push


================================================
FILE: .github/workflows/node.js.yml
================================================
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions

name: Node.js CI

on:
  push:
    branches: [development]
  pull_request:
    branches: [development]

jobs:
  build:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x]

    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm install
      - run: npm run build --if-present
      - run: npm test


================================================
FILE: .gitignore
================================================
node_modules/*
typings/*
public/webfonts/*
*.log
*.js.map
*.js
*.js.LICENSE
*.js.LICENSE.txt
!jest.config.js
*.css
*.tmp.txt
*.gitignore.*
.tscache
!.vscode/launch.json
.baseDir
!Gruntfile.js
!webpack.*.js
!local_node_modules/**
.idea


================================================
FILE: .nvmrc
================================================
18.15.0

================================================
FILE: .prettierrc.json
================================================
{
  "printWidth": 80,
  "tabWidth": 2,
  "useTabs": false,
  "semi": true,
  "singleQuote": false,
  "trailingComma": "none",
  "bracketSpacing": true,
  "jsxBracketSameLine": false,
  "arrowParens": "avoid"
}


================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
  - "node"
cache:
  directories:
    - $HOME/.mongodb-binaries


================================================
FILE: .vscode/launch.json
================================================
{
  // Use IntelliSense to learn about possible Node.js debug attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run Improved Initiative Server",
      "program": "${workspaceRoot}\\server\\server.js",
      "runtimeVersion": "18.15.0",
      "autoAttachChildProcesses": true,
      "outFiles": [],
      "env": {
        "NODE_ENV": "development",
        "BASE_URL": "http://localhost",
        "PATREON_URL": "https://api.patreon.com/campaigns/716070/posts",
        "DEFAULT_ACCOUNT_LEVEL": "free",
        //"DEFAULT_ACCOUNT_LEVEL": "epicinitiative",
        "PORT": "80"
      }
    },
    {
      "type": "node",
      "request": "launch",
      "name": "Debug Tests for this File",
      "program": "${workspaceRoot}/node_modules/jest/bin/jest",
      "args": ["-i", "${fileBasenameNoExtension}"],
      "internalConsoleOptions": "openOnSessionStart",
      "outFiles": ["${fileDirname}"]
    }
  ]
}


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Improved Initiative

Thanks for your interest in contributing to Improved Initiative! It means a lot to me that you'd like to spend your time to help me build this project, and I'd be happy to review any issues or pull requests you'd like to submit.

If this is your first time contributing to an open source project, don't sweat it. It's my first time maintaining an open source project. We'll learn together- look over [this article](https://opensource.guide/how-to-contribute/) for some general advice.

## Getting Started

Evan uses and recommends [Visual Studio Code](https://code.visualstudio.com/) when developing Improved Initiative. You can hit F5 and accept the default Node.js environment to get the server running. You can set environment variables as described in the README in this default launch.json.

Most of the Typescript code for the frontend lives in `/client/`. Everything is in the process of gradually being migrated to React, so use React components in .tsx files when possible.

The backend lives in `/server/`. It's also Typescript, but it has its own build step. The server must be restarted to take new builds.

Shared data structures are located in `/common/`. Any interfaces in this folder are saved to localstorage or databases, so only make additive, backwards-compatible changes to them and consider backwards compatability when handling them.

**I'm happy to provide guidance on how to approach any open issue.**

## Guidelines

Here is a short list of coding guidelines (adapted from [TypeScript Coding Guidelines](https://github.com/Microsoft/TypeScript/wiki/Coding-guidelines)). This is not an exhaustive guide, so please be willing to make requested modifications to your code.

### General

- Open pull requests against the `development` branch.
- Link your pull request to an open [issue](https://github.com/cynicaloptimist/improved-initiative/labels/help%20wanted) with the `help wanted` tag.
- Include at least one test for your code.
- Don't add any game content that isn't covered by the [Open-Gaming License](http://dnd.wizards.com/articles/features/systems-reference-document-srd).

### Names

- Use whole words, not abbreviations, in names.
- Use PascalCase for type names and public methods.
- Do not use "I" as a prefix for interface names.
- Use camelCase for local variables and private properties.
- Do not use "\_" as a prefix for private properties.

## Epic Initiative

While Improved Initiative is open source, the MIT license allows anyone to use this code to make a profit. I've chosen to make a subset of the app's features available as rewards to [Patreon](https://www.patreon.com/improvedinitiative) subscribers as "Epic Initiative". At the time of this writing, this mainly covers cosmetic benefits associated with the Player View such as custom CSS. As the license indicates, you are always free to run your own instance of Improved Initiative and modify this functionality to meet your needs.

### thanks.ts

Epic Initiative is also granted to the app's GitHub contributors. If you contribute a substantial pull request, please add your name/alias, Github URL, and Patreon ID to `thanks.ts`. Patreon doesn't surface your Patreon Id anywhere in their UI as far as I can tell, but you can find it by inspecting any Patreon link to your profile.


================================================
FILE: Dockerfile
================================================
FROM node:carbon
ARG NODE_ENV
ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
ENV PATH=$PATH:/home/node/.npm-global/bin
RUN npm install -g grunt

WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install

COPY . .
RUN grunt --no-color copy

ENV NODE_ENV=${NODE_ENV}
RUN if [ "$NODE_ENV" = "production" ]; then grunt --no-color build_min; else grunt --no-color build_dev; fi

EXPOSE 80
CMD [ "node", "server/server.js" ]


================================================
FILE: Gruntfile.js
================================================
const appVersion = require("./package.json").version;

module.exports = function (grunt) {
  grunt.loadNpmTasks("grunt-ts");
  grunt.loadNpmTasks("grunt-webpack");
  grunt.loadNpmTasks("grunt-contrib-less");
  grunt.loadNpmTasks("grunt-contrib-watch");

  grunt.initConfig({
    pkg: grunt.file.readJSON("package.json"),
    ts: {
      server: {
        tsconfig: "./server/tsconfig.json"
      }
    },
    webpack: {
      options: {
        keepalive: false
      },
      dev: require("./webpack.config.dev"),
      prod: require("./webpack.config.prod")
    },
    less: {
      default: {
        files: {
          ["public/css/improved-initiative." + appVersion + ".css"]: [
            "lesscss/improved-initiative.less"
          ]
        }
      }
    },
    watch: {
      tsserver: {
        files: "server/**/*.ts",
        tasks: ["ts:server"]
      },
      lesscss: {
        files: "lesscss/**/*.less",
        tasks: ["less"]
      }
    }
  });

  grunt.registerTask("build_dev", ["webpack:dev", "ts:server", "less"]);
  grunt.registerTask("build_min", ["webpack:prod", "ts:server", "less"]);
  grunt.registerTask("server_watch", ["ts:server", "watch"]);
  grunt.registerTask("default", ["build_dev", "watch"]);
};


================================================
FILE: PRIVACY.md
================================================
## TERMS OF SERVICE

Improved Initiative is for entertainment purposes only, and is not responsible for dead player characters or other combat mishaps that may occur during its use. Improved Initiative is offered with no warranty of any kind. Improved Initiative is not responsible or liable for the deletion of any user data used by or maintained by the app. You are responsible for backing up any data that you use with the app.

## PRIVACY POLICY

In order to provide the web app, Improved Initiative collects some information from users, and it uses cookies. If you don't log in, the app collects your IP address and session identifier. If you log in with Patreon, the app collects your email address, and a unique identifier associated with your Patreon account as a key for your app data.

In order to improve the app's user experience, the app can collect data about how it is being used. If you opt in, your usage patterns will be tracked and stored by the app. This helps determine what features should be built next.

None of your personal information is ever sold to a third party. All of the app's behaviors are documented in the source code on [GitHub](https://github.com/cynicaloptimist/improved-initiative). In the case of any contradictions between this document and the source code, the source code takes precedence.


================================================
FILE: Procfile
================================================
web: node server/server.js

================================================
FILE: README.md
================================================
# improved-initiative

_Combat tracker for Dungeons and Dragons (D&D) 5th Edition_

The official Improved Initiative app lives at https://improvedinitiative.app/

## Local Development

### Requirements

- [Node.js](https://nodejs.org/en/) (see package.json for specific version)

### Setup

- Clone the repo to a folder on your computer
- Open the cloned folder in a code editor such as [Visual Studio Code](https://code.visualstudio.com/)
- Open a terminal window (Powershell is the recommend terminal application for this project)
- Run the following commands in the Terminal window to build the dev environment code:

```
npm install
npx grunt
```

- To get the dev server running, you can either:
  - Press `F5` in Visual Studio Code _or_
  - In a new terminal window run `npm run start`
- Once the server is running, visit <http://localhost> in a web browser to view a development version of the UI that responds to your code changes.
- Every time you make a change, wait for it to finish compiling then manually reload your browser.

Development of Improved Initiative is supported through [Patreon](https://www.patreon.com/improvedinitiative).

To learn more about how to contribute code to Improved Initiative, refer to [CONTRIBUTING.md](./CONTRIBUTING.md).

### Linting

Improved Initiative uses Eslint with prettier to lint the code files.

Linting happens automatically on commit, but you can also run it manually via: `npm run lint`.

### App Settings

You can configure your instance of Improved Initiative with these settings. All are optional, basic functionality should work if you don't specify any.

- `PORT` - Defaults to 80
- `NODE_ENV` - Set to "production" to satisfy react, set to "development" to disable html view caching.
- `BASE_URL` - Used in absolute URLs on client side. Falls back to relative urls if unavailable. This is the canonical URL for Patreon callback and browser localStorage.
- `SESSION_SECRET` - Used to keep session continuity through app restarts or something. Handed to express-session.
- `DEFAULT_ACCOUNT_LEVEL` - Set to "accountsync" or "epicinitiative" to grant rewards to all users. Useful if you have no DB.
- `DEFAULT_PATREON_ID` - Set the dummy Patreon user id when running with `DEFAULT_ACCOUNT_LEVEL` set.
- `DB_CONNECTION_STRING` - Provide a DB connection string for session and user account storage. In memory Mongo DB will be used otherwise, which is cleared on app restart.
- `METRICS_DB_CONNECTION_STRING` - Provide a DB connection string to write metrics to.
- `PATREON_URL`, `PATREON_CLIENT_ID`, `PATREON_CLIENT_SECRET` - Configuration for Patreon integration

### Docker

Running Improved Initiative within Docker is possible, but completely optional and currently experimental. Proceed with caution and when in doubt, refer to the [Docker documentation](https://docs.docker.com/).

#### Building the Docker Image

To build the docker image with a development build, run:

`docker build -t improved-initiative:latest .`

To build the image with a production build, run:

`docker build --build-arg NODE_ENV=production -t improved-initiative:prod .`

#### Running the App in a Docker Container

To start the application within the container, run:

`docker run -p80:80 --name improved-initiative improved-initiative:latest`

Or, to run the production build:

`docker run -p80:80 --name improved-initiative improved-initiative:prod`

#### Stopping and Removing the Container

Assuming you started the container with the name `improved-initiative` as shown above, the following commands will stop the container and then remove it:

`docker stop improved-initiative`

`docker rm improved-initiative`

## License

The Improved Initiative app is made available under the [MIT](license) license.


================================================
FILE: _config.yml
================================================
theme: jekyll-theme-cayman


================================================
FILE: about.md
================================================
# About Improved Initiative

**Improved Initiative** was created by [Evan Bailey](mailto:improvedinitiativedev@gmail.com). All Wizards of the Coast content provided under terms of the [Open Gaming License Version 1.0a](http://media.wizards.com/2016/downloads/SRD-OGL_V1.1.pdf).


================================================
FILE: babel.config.json
================================================
{
  "presets": [
    ["@babel/preset-env", { "targets": { "node": "current" } }],
    ["@babel/preset-typescript", { "allExtensions": true, "isTSX": true }]
  ]
}


================================================
FILE: client/.baseDir.ts
================================================
// Ignore this file. See https://github.com/grunt-ts/grunt-ts/issues/77


================================================
FILE: client/.eslintrc.json
================================================
{
  "parserOptions": {
    "tsconfigRootDir": "client",
    "project": "./tsconfig.eslint.json"
  },
  "env": {
    "es6": true
  }
}


================================================
FILE: client/Account/Account.ts
================================================
import { ListingMeta } from "../../common/Listable";
import { Settings } from "../../common/Settings";

export interface Account {
  settings: Settings;
  statblocks: ListingMeta[];
  playercharacters: ListingMeta[];
  persistentcharacters: ListingMeta[];
  spells: ListingMeta[];
  encounters: ListingMeta[];
}


================================================
FILE: client/Account/AccountClient.test.ts
================================================
import { Listing, ListingOrigin } from "../Library/Listing";
import { LegacySynchronousLocalStore } from "../Utility/LegacySynchronousLocalStore";
import { Store } from "../Utility/Store";
import { getUnsyncedItemsFromListings } from "./AccountClient";

async function fakeListing(
  id: string,
  name: string,
  listingOrigin: ListingOrigin
) {
  const listing = new Listing(
    {
      Id: id,
      Name: name,
      Link: "Test",
      FilterDimensions: {},
      Path: "",
      SearchHint: "",
      LastUpdateMs: 0
    },
    listingOrigin
  );
  if (listingOrigin == "localStorage") {
    LegacySynchronousLocalStore.Save("Test", id, { Id: id, Name: name });
  }
  if (listingOrigin == "localAsync") {
    await Store.Save("Test", id, { Id: id, Name: name });
  }
  return listing;
}

describe("getUnsyncedItemsFromListings", () => {
  test("Should find unsynced local items", async () => {
    const localListing = await fakeListing("item1", "Unsynced", "localStorage");
    const remoteListing = await fakeListing("item2", "Synced", "account");
    const unsyncedItems = await getUnsyncedItemsFromListings([
      localListing,
      remoteListing
    ]);
    expect(unsyncedItems).toEqual([
      {
        Id: "item1",
        Name: "Unsynced",
        Path: "",
        Version: "unknown"
      }
    ]);
  });

  test("Should find unsynced localAsync items", async () => {
    const localListing = await fakeListing("item1", "Unsynced", "localAsync");
    const remoteListing = await fakeListing("item2", "Synced", "account");
    const unsyncedItems = await getUnsyncedItemsFromListings([
      localListing,
      remoteListing
    ]);
    expect(unsyncedItems).toEqual([
      {
        Id: "item1",
        Name: "Unsynced",
        Path: "",
        Version: "unknown"
      }
    ]);
  });

  test("Should omit synced items", async () => {
    const localListing = await fakeListing("item1", "Synced", "localAsync");
    const remoteListing = await fakeListing("item1", "Synced", "account");

    const localUnsyncedListing = await fakeListing(
      "item2",
      "Unsynced",
      "localAsync"
    );

    const unsyncedItems = await getUnsyncedItemsFromListings([
      localListing,
      remoteListing,
      localUnsyncedListing
    ]);

    expect(unsyncedItems).toEqual([
      {
        Id: "item2",
        Name: "Unsynced",
        Path: "",
        Version: "unknown"
      }
    ]);
  });
});


================================================
FILE: client/Account/AccountClient.ts
================================================
import axios from "axios";
import * as _ from "lodash";

import * as retry from "retry";

import { Listable } from "../../common/Listable";
import { PersistentCharacter } from "../../common/PersistentCharacter";
import { SavedEncounter } from "../../common/SavedEncounter";
import { Settings } from "../../common/Settings";
import { Spell } from "../../common/Spell";
import { StatBlock } from "../../common/StatBlock";
import { env } from "../Environment";
import { Libraries } from "../Library/Libraries";
import { Listing } from "../Library/Listing";
import { Account } from "./Account";

const DEFAULT_BATCH_SIZE = 10;
const ENCOUNTER_BATCH_SIZE = 1;

export class AccountClient {
  public GetAccount(callBack: (user: Account | null) => void): true | void {
    if (!env.HasStorage) {
      return callBack(null);
    }

    axios
      .get("/my")
      .then(response => callBack(response.data))
      .catch(err => {
        console.error("Could not get account details", err);
      });

    return true;
  }

  public async DeleteAccount(): Promise<any> {
    if (!env.HasStorage) {
      return emptyPromise();
    }

    return axios.delete("/my");
  }

  public async GetFullAccount(): Promise<any> {
    if (!env.HasStorage) {
      return emptyPromise();
    }

    const response = await axios.get("/my/fullaccount");
    return response.data;
  }

  public async SaveAllUnsyncedItems(
    libraries: Libraries,
    messageCallback: (message: string) => void
  ): Promise<void> {
    if (!env.HasStorage) {
      return;
    }

    const promises = [
      saveEntitySet(
        await getUnsyncedItemsFromListings(
          libraries.StatBlocks.GetAllListings()
        ),
        "statblocks",
        DEFAULT_BATCH_SIZE,
        messageCallback
      ),
      saveEntitySet(
        await getUnsyncedItemsFromListings(
          libraries.PersistentCharacters.GetAllListings()
        ),
        "persistentcharacters",
        DEFAULT_BATCH_SIZE,
        messageCallback
      ),
      saveEntitySet(
        await getUnsyncedItemsFromListings(libraries.Spells.GetAllListings()),
        "spells",
        DEFAULT_BATCH_SIZE,
        messageCallback
      ),
      saveEntitySet(
        await getUnsyncedItemsFromListings(
          libraries.Encounters.GetAllListings()
        ),
        "encounters",
        ENCOUNTER_BATCH_SIZE,
        messageCallback
      )
    ];

    await Promise.all(promises);

    return messageCallback("Account Sync complete.");
  }

  public SaveSettings(settings: Settings): Promise<Settings> {
    return saveEntity<Settings>(settings, "settings");
  }

  public SaveStatBlock(statBlock: StatBlock): Promise<StatBlock> {
    return saveEntity<StatBlock>(statBlock, "statblocks");
  }

  public DeleteStatBlock(statBlockId: string): Promise<any> {
    return deleteEntity(statBlockId, "statblocks");
  }

  public SavePlayerCharacter(playerCharacter: StatBlock): Promise<StatBlock> {
    return saveEntity<StatBlock>(playerCharacter, "playercharacters");
  }

  public DeletePlayerCharacter(statBlockId: string): Promise<any> {
    return deleteEntity(statBlockId, "playercharacters");
  }

  public SavePersistentCharacter(
    persistentCharacter: PersistentCharacter
  ): Promise<PersistentCharacter> {
    return saveEntity<PersistentCharacter>(
      persistentCharacter,
      "persistentcharacters"
    );
  }

  public DeletePersistentCharacter(
    persistentCharacterId: string
  ): Promise<any> {
    return deleteEntity(persistentCharacterId, "persistentcharacters");
  }

  public SaveEncounter(encounter: SavedEncounter): Promise<SavedEncounter> {
    return saveEntity<SavedEncounter>(encounter, "encounters");
  }

  public DeleteEncounter(encounterId: string): Promise<any> {
    return deleteEntity(encounterId, "encounters");
  }

  public SaveSpell(spell: Spell): Promise<Spell> {
    return saveEntity<Spell>(spell, "spells");
  }

  public DeleteSpell(spellId: string): Promise<any> {
    return deleteEntity(spellId, "spells");
  }

  private static SanitizeForId(str: string) {
    return str.replace(/ /g, "_").replace(/[^a-zA-Z0-9_]/g, "");
  }

  public static MakeId(name: string, path?: string): string {
    if (path?.length) {
      return this.SanitizeForId(path) + "-" + this.SanitizeForId(name);
    } else {
      return this.SanitizeForId(name);
    }
  }
}

function emptyPromise() {
  return Promise.resolve(null);
}

function saveEntity<T>(entity: T, entityType: string): Promise<T | null> {
  if (!env.HasStorage) {
    return Promise.resolve(null);
  }

  const saveOperation = retry.operation({ retries: 3 });

  return new Promise(resolve => {
    saveOperation.attempt(() => {
      axios
        .post(`/my/${entityType}/`, JSON.stringify(entity), {
          headers: { "content-type": "application/json" }
        })
        .then(() => resolve(entity))
        .catch(err => {
          if (saveOperation.retry(new Error(err))) {
            return;
          }
          console.warn(`Failed to save ${entityType}: ${err}`);
          resolve(null);
        });
    });
  });
}

export async function getUnsyncedItemsFromListings(
  items: Listing<Listable>[]
): Promise<Listable[]> {
  const unsynced = await getUnsyncedItems(items);
  return sanitizeItems(unsynced);
}

async function getUnsyncedItems(items: Listing<Listable>[]) {
  const itemsByName: _.Dictionary<Listing<Listable>> = {};
  for (const item of items) {
    const name = [item.Meta().Path + item.Meta().Name].toString();
    if (itemsByName[name] == undefined) {
      itemsByName[name] = item;
    } else {
      if (item.Origin == "account") {
        itemsByName[name] = item;
      }
    }
  }

  const unsynced = _.values(itemsByName).filter(
    i => i.Origin == "localStorage" || i.Origin == "localAsync"
  );

  const unsyncedItems = await Promise.all(
    unsynced.map(
      async listing =>
        await listing.GetWithTemplate({
          Id: listing.Meta().Id,
          Name: listing.Meta().Name,
          Path: listing.Meta().Path,
          Version: process.env.VERSION || "unknown"
        })
    )
  );

  return unsyncedItems;
}

function sanitizeItems(items: Listable[]) {
  return items.map(i => {
    if (!i.Id) {
      i.Id = AccountClient.MakeId(i.Name);
    } else {
      i.Id = i.Id.replace(".", "_");
    }

    if (!i.Version) {
      i.Version = "legacy";
    }

    return i;
  });
}

async function saveEntitySet<Listable>(
  entitySet: Listable[],
  entityType: string,
  batchSize: number,
  messageCallback: (message: string) => void
) {
  if (!env.HasStorage || !entitySet.length) {
    return;
  }

  for (let cursor = 0; cursor < entitySet.length; cursor += batchSize) {
    const batch = entitySet.slice(cursor, cursor + batchSize);
    try {
      await axios.post(`/my/${entityType}/`, JSON.stringify(batch), {
        headers: { "content-type": "application/json" }
      });
      messageCallback(`Syncing ${cursor}/${entitySet.length} ${entityType}`);
    } catch (err) {
      messageCallback(err);
    }
  }
}

function deleteEntity(entityId: string, entityType: string) {
  if (!env.HasStorage) {
    return emptyPromise();
  }

  return axios.delete(`/my/${entityType}/${entityId}`);
}


================================================
FILE: client/App.tsx
================================================
import * as React from "react";
import { HTML5Backend } from "react-dnd-html5-backend";

import { TrackerViewModel } from "./TrackerViewModel";
import { useSubscription } from "./Combatant/linkComponentToObservables";
import { CurrentSettings } from "./Settings/Settings";
import { SettingsContext } from "./Settings/SettingsContext";
import { SettingsPane } from "./Settings/components/SettingsPane";
import { AccountClient } from "./Account/AccountClient";
import { Tutorial } from "./Tutorial/Tutorial";
import { env } from "./Environment";
import { TextEnricherContext } from "./TextEnricher/TextEnricher";
import { LegacySynchronousLocalStore } from "./Utility/LegacySynchronousLocalStore";
import { DndProvider } from "react-dnd";
import { interfacePriorityClass } from "./Layout/interfacePriorityClass";
import { centerColumnView } from "./Layout/centerColumnView";
import { ThreeColumnLayout } from "./Layout/ThreeColumnLayout";
import { LibraryManager } from "./Library/Manager/LibraryManager";
import { LibrariesContext, useLibraries } from "./Library/Libraries";
import { Store } from "./Utility/Store";
import { Settings } from "../common/Settings";

/*
 * This file is new as of 05/2020. Most of the logic was extracted from TrackerViewModel.
 * TrackerViewModel was the top level Knockout viewmodel for binding to ko components.
 */

export function App(props: { tracker: TrackerViewModel }): JSX.Element {
  const { tracker } = props;
  const settings = useSubscription<Settings>(CurrentSettings);

  const settingsVisible = useSubscription(tracker.SettingsVisible);
  const tutorialVisible = useSubscription(tracker.TutorialVisible);
  const libraryManagerPane = useSubscription(tracker.LibraryManagerPane);
  const librariesVisible = useSubscription(tracker.LibrariesVisible);
  const statblockEditorProps = useSubscription(tracker.StatBlockEditorProps);
  const spellEditorProps = useSubscription(tracker.SpellEditorProps);
  const prompts = useSubscription(tracker.PromptQueue.GetPrompts);

  const encounterFlowState = useSubscription(
    tracker.Encounter.EncounterFlow.State
  );

  const isACombatantSelected = useSubscription(
    tracker.CombatantCommander.HasSelected
  );

  const libraries = useLibraries(settings, new AccountClient(), () => {
    tracker.LoadAutoSavedEncounterIfAvailable();
  });

  tracker.SetLibraries(libraries);

  const centerColumn = centerColumnView(statblockEditorProps, spellEditorProps);
  const interfacePriority = interfacePriorityClass(
    centerColumn,
    librariesVisible,
    prompts.length > 0,
    isACombatantSelected,
    encounterFlowState
  );

  const blurVisible = tutorialVisible || settingsVisible;

  return (
    <DndProvider backend={HTML5Backend}>
      <SettingsContext.Provider value={settings}>
        <TextEnricherContext.Provider value={tracker.StatBlockTextEnricher}>
          <LibrariesContext.Provider value={libraries}>
            <div className={"encounter-view " + interfacePriority}>
              {blurVisible && (
                <div className="modal-blur" onClick={tracker.CloseSettings} />
              )}
              {settingsVisible && (
                <SettingsPane
                  handleNewSettings={tracker.SaveUpdatedSettings}
                  encounterCommands={tracker.EncounterToolbar}
                  combatantCommands={tracker.CombatantCommander.Commands}
                  reviewPrivacyPolicy={tracker.ReviewPrivacyPolicy}
                  repeatTutorial={tracker.RepeatTutorial}
                  closeSettings={() => tracker.SettingsVisible(false)}
                  libraries={libraries}
                  accountClient={new AccountClient()}
                />
              )}
              {tutorialVisible && (
                <Tutorial
                  onClose={() => {
                    tracker.TutorialVisible(false);
                    LegacySynchronousLocalStore.Save(
                      LegacySynchronousLocalStore.User,
                      "SkipIntro",
                      true
                    );
                  }}
                />
              )}
              {!env.IsLoggedIn && (
                <a className="login button" href={env.PatreonLoginUrl}>
                  Log In with Patreon
                </a>
              )}
              {libraryManagerPane ? (
                <LibraryManager
                  libraries={libraries}
                  librariesCommander={tracker.LibrariesCommander}
                  closeManager={() => tracker.LibraryManagerPane(null)}
                  initialPane={libraryManagerPane}
                />
              ) : (
                <ThreeColumnLayout tracker={tracker} />
              )}
            </div>
          </LibrariesContext.Provider>
        </TextEnricherContext.Provider>
      </SettingsContext.Provider>
    </DndProvider>
  );
}


================================================
FILE: client/AutosavedEncounterTest.test.tsx
================================================
import { renderHook, act } from "@testing-library/react-hooks";

import { TrackerViewModel } from "./TrackerViewModel";
import { io } from "socket.io-client";
import { EncounterState } from "../common/EncounterState";
import { CombatantState } from "../common/CombatantState";
import { StatBlock } from "../common/StatBlock";
import { probablyUniqueString } from "../common/Toolbox";
import { LegacySynchronousLocalStore } from "./Utility/LegacySynchronousLocalStore";
import { CurrentSettings, InitializeSettings } from "./Settings/Settings";
import { useLibraries } from "./Library/Libraries";
import { MockAccountClient } from "./MockAccountClient";
import { InitializeTestSettings } from "./test/InitializeTestSettings";

function CombatantStateWithName(name: string): CombatantState {
  const statBlock = StatBlock.Default();
  statBlock.Name = name;
  return {
    Id: probablyUniqueString(),
    StatBlock: statBlock,
    Alias: "",
    IndexLabel: null,
    CurrentHP: statBlock.HP.Value,
    CurrentNotes: "",
    TemporaryHP: 0,
    Hidden: false,
    RevealedAC: false,
    Initiative: 0,
    Tags: [],
    RoundCounter: 0,
    ElapsedSeconds: 0,
    InterfaceVersion: process.env.VERSION || "unknown"
  };
}

describe("Autosaved Encounters", () => {
  it("loads simple combatants", async () => {
    InitializeTestSettings({
      PreloadedContent: {
        BasicRules: false,
        Open5eContent: false
      }
    });

    const encounter: EncounterState<CombatantState> = {
      ActiveCombatantId: null,
      RoundCounter: 0,
      ElapsedSeconds: 0,
      BackgroundImageUrl: null,
      Combatants: [CombatantStateWithName("1"), CombatantStateWithName("2")]
    };
    LegacySynchronousLocalStore.Save(
      LegacySynchronousLocalStore.AutoSavedEncounters,
      LegacySynchronousLocalStore.DefaultSavedEncounterId,
      encounter
    );

    const viewModel = new TrackerViewModel(io());

    const libraries = renderHook(() =>
      useLibraries(CurrentSettings(), MockAccountClient(), () => {})
    );

    viewModel.SetLibraries(libraries.result.current);
    viewModel.LoadAutoSavedEncounterIfAvailable();
    const combatants = viewModel.Encounter.Combatants();
    expect(combatants.map(c => c.DisplayName())).toEqual(["1", "2"]);
  });

  it.todo("loads persistent characters from account");
  it.todo("loads persistent characters from localAsync");
});


================================================
FILE: client/CombatFooter/CombatFooter.tsx
================================================
import * as React from "react";
import { EventLog } from "../Widgets/EventLog";
import { Encounter } from "../Encounter/Encounter";
import { SettingsContext } from "../Settings/SettingsContext";
import { useSubscription } from "../Combatant/linkComponentToObservables";
import { EncounterDifficulty } from "../Widgets/DifficultyCalculator";
import ReactMarkdown from "react-markdown";

type CombatFooterProps = {
  eventLog: EventLog;
  encounter: Encounter;
};

export function CombatFooter(props: CombatFooterProps) {
  const settingsContext = React.useContext(SettingsContext);
  const allEvents = useSubscription(props.eventLog.Events);
  const elapsedRounds = useSubscription(
    props.encounter.EncounterFlow.CombatTimer.ElapsedRounds
  );
  const encounterDifficulty = useSubscription(props.encounter.Difficulty);

  const [fullLogVisible, setFullLogVisible] = React.useState(false);
  const togglerButtonCSS = fullLogVisible ? "fa-caret-down" : "fa-caret-up";

  return (
    <div className="combat-footer">
      {fullLogVisible && <FullEventLog eventsTail={allEvents.slice(1)} />}
      <div className="footer-bar">
        <i
          className={"fa-clickable " + togglerButtonCSS}
          onClick={() => setFullLogVisible(!fullLogVisible)}
        ></i>
        <span className="latest-event">
          <EventLogItem eventMarkdown={allEvents[0]} />
        </span>
        {settingsContext.TrackerView.DisplayTurnTimer && (
          <TurnTimerReadout
            observableReadout={props.encounter.EncounterFlow.TurnTimerReadout}
          />
        )}
        {settingsContext.TrackerView.DisplayRoundCounter && (
          <span className="round-counter">Current Round: {elapsedRounds}</span>
        )}
        {settingsContext.TrackerView.DisplayDifficulty && (
          <span className="encounter-challenge">
            {getDifficultyString(encounterDifficulty)}
          </span>
        )}
      </div>
    </div>
  );
}

function getDifficultyString(difficulty: EncounterDifficulty) {
  const xpString = difficulty.EarnedExperience + " XP";
  if (difficulty.Difficulty.length == 0) {
    return xpString;
  }
  return difficulty.Difficulty + ": " + xpString;
}

function FullEventLog(props: { eventsTail: string[] }) {
  const eventsEndRef = React.useRef<HTMLDivElement>(null);

  const scrollToBottom = () => {
    eventsEndRef.current?.scrollIntoView(true);
  };

  React.useEffect(scrollToBottom, []);

  return (
    <ul className="event-log">
      {props.eventsTail.reverse().map((eventMarkdown, index) => (
        <li key={index}>
          <EventLogItem eventMarkdown={eventMarkdown} />
        </li>
      ))}
      <div ref={eventsEndRef} />
    </ul>
  );
}

function TurnTimerReadout(props: {
  observableReadout: KnockoutObservable<string>;
}) {
  const turnTimerReadout = useSubscription(props.observableReadout);

  return <span className="turn-timer">{turnTimerReadout}</span>;
}

function EventLogItem(props: { eventMarkdown: string }) {
  return (
    <ReactMarkdown
      components={{
        a: ({ node, ...props }) => (
          <a {...props} target="_blank" rel="noopener" />
        )
      }}
    >
      {props.eventMarkdown}
    </ReactMarkdown>
  );
}


================================================
FILE: client/Combatant/Combatant.test.ts
================================================
import { StatBlock } from "../../common/StatBlock";
import { Encounter } from "../Encounter/Encounter";
import { InitializeTestSettings } from "../test/InitializeTestSettings";
import { buildEncounter } from "../test/buildEncounter";
import { ToPlayerViewCombatantState } from "./ToPlayerViewCombatantState";

describe("Combatant", () => {
  let encounter: Encounter;
  beforeEach(() => {
    InitializeTestSettings();
    encounter = buildEncounter();
  });

  test("Should have its Max HP set from the statblock", () => {
    const combatant = encounter.AddCombatantFromStatBlock({
      ...StatBlock.Default(),
      HP: { Value: 10, Notes: "" }
    });

    expect(combatant.MaxHP()).toBe(10);
  });

  test("Should update its Max HP when its statblock is updated", () => {
    const combatant = encounter.AddCombatantFromStatBlock({
      ...StatBlock.Default(),
      Player: "player"
    });

    combatant.StatBlock({
      ...StatBlock.Default(),
      HP: { Value: 15, Notes: "" }
    });
    expect(combatant.MaxHP()).toBe(15);
  });

  test("Should notify the encounter when its statblock is updated", () => {
    const combatant = encounter.AddCombatantFromStatBlock({
      ...StatBlock.Default(),
      Player: "player"
    });
    const combatantsSpy = jest.fn();
    encounter.Combatants.subscribe(combatantsSpy);

    combatant.StatBlock({
      ...StatBlock.Default(),
      HP: { Value: 15, Notes: "" }
    });
    expect(combatantsSpy).toBeCalled();
  });

  describe("ToPlayerViewCombatantState", () => {
    test("Should show full HP for player characters", () => {
      const combatant = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        Player: "player"
      });
      const playerViewCombatantState = ToPlayerViewCombatantState(combatant);
      expect(playerViewCombatantState.HPDisplay).toEqual("1/1");
    });

    test("Should show qualitative HP for creatures", () => {
      const combatant = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default()
      });
      const playerViewCombatantState = ToPlayerViewCombatantState(combatant);
      expect(playerViewCombatantState.HPDisplay).toEqual(
        "<span class='healthyHP'>Healthy</span>"
      );
    });
  });
});


================================================
FILE: client/Combatant/Combatant.ts
================================================
import * as ko from "knockout";

import { CombatantState } from "../../common/CombatantState";
import { InitiativeSpecialRoll, StatBlock } from "../../common/StatBlock";
import { probablyUniqueString } from "../../common/Toolbox";
import { Encounter } from "../Encounter/Encounter";
import { UpdatePersistentCharacter } from "../Library/Libraries";
import { CurrentSettings } from "../Settings/Settings";
import { NotifyTutorialOfAction } from "../Tutorial/NotifyTutorialOfAction";
import { Metrics } from "../Utility/Metrics";
import { CombatTimer } from "../Widgets/CombatTimer";
import { Tag } from "./Tag";

export class Combatant {
  constructor(
    combatantState: CombatantState,
    public Encounter: Encounter
  ) {
    let oldStatBlockName = combatantState.StatBlock.Name;
    this.Id = "" + combatantState.Id; //legacy Id may be a number
    this.PersistentCharacterId = combatantState.PersistentCharacterId || null;

    this.StatBlock(combatantState.StatBlock);

    this.processStatBlock();

    this.StatBlock.subscribe(newStatBlock => {
      this.processStatBlock(oldStatBlockName);
      oldStatBlockName = newStatBlock.Name;
    });

    this.CurrentHP = ko.observable(combatantState.CurrentHP);
    this.CurrentNotes = ko.observable(combatantState.CurrentNotes || "");

    this.processCombatantState(combatantState);

    this.Initiative.subscribe(newInitiative => {
      const groupId = this.InitiativeGroup();
      if (!this.updatingGroup && groupId) {
        this.updatingGroup = true;
        this.Encounter.Combatants().forEach(combatant => {
          if (combatant.InitiativeGroup() === groupId) {
            combatant.Initiative(newInitiative);
          }
        });
        this.updatingGroup = false;
      }
    });
  }
  public Id = probablyUniqueString();
  public PersistentCharacterId: string | null = null;
  public Alias = ko.observable("");
  public TemporaryHP = ko.observable(0);
  public Tags = ko.observableArray<Tag>();
  public Initiative = ko.observable(0);
  public InitiativeGroup = ko.observable<string>(null);
  public StatBlock = ko.observable<StatBlock>(StatBlock.Default());
  public Hidden = ko.observable(false);
  public RevealedAC = ko.observable(false);
  public IndexLabel = ko.observable(0);
  public Color = ko.observable("");
  public ReactionsSpent = ko.observable(0);
  public IsPendingRemoval = ko.observable(false);

  public CombatTimer = new CombatTimer();

  public CurrentHP: KnockoutObservable<number>;
  public CurrentNotes: KnockoutObservable<string>;
  public PlayerDisplayHP: KnockoutComputed<string>;
  private updatingGroup = false;

  private processStatBlock(oldStatBlockName?: string) {
    if (oldStatBlockName !== undefined) {
      this.UpdateIndexLabel(oldStatBlockName);
    }

    this.setAutoInitiativeGroup();
    if (oldStatBlockName !== undefined) {
      this.Encounter.Combatants.notifySubscribers();
    }
  }

  private processCombatantState(savedCombatant: CombatantState) {
    this.IndexLabel(savedCombatant.IndexLabel || 0);
    this.CurrentHP(savedCombatant.CurrentHP);
    this.CurrentNotes(savedCombatant.CurrentNotes || "");
    this.TemporaryHP(savedCombatant.TemporaryHP);
    this.Initiative(savedCombatant.Initiative);
    this.InitiativeGroup(
      savedCombatant.InitiativeGroup || this.InitiativeGroup()
    );
    this.Alias(savedCombatant.Alias);
    this.Tags(Tag.FromTagStates(savedCombatant.Tags, this));
    this.Hidden(savedCombatant.Hidden);
    this.RevealedAC(savedCombatant.RevealedAC);
    this.Color(savedCombatant.Color || "");
    this.ReactionsSpent(savedCombatant.ReactionsSpent || 0);
    this.CombatTimer.SetElapsedRounds(savedCombatant.RoundCounter || 0);
    this.CombatTimer.SetElapsedSeconds(savedCombatant.ElapsedSeconds || 0);
  }

  public AttachToPersistentCharacterLibrary(
    updatePersistentCharacter: UpdatePersistentCharacter
  ) {
    const persistentCharacterId = this.PersistentCharacterId;
    if (persistentCharacterId == null) {
      throw "Combatant is not a persistent character";
    }

    this.CurrentHP.subscribe(async c => {
      return await updatePersistentCharacter(persistentCharacterId, {
        CurrentHP: c
      });
    });

    this.CurrentNotes.subscribe(async n => {
      return await updatePersistentCharacter(persistentCharacterId, {
        Notes: n
      });
    });
  }

  public UpdateIndexLabel(oldName?: string) {
    const name = this.StatBlock().Name;
    const counts = this.Encounter.CombatantCountsByName();
    if (name == oldName) {
      return;
    }
    if (oldName) {
      if (!counts[oldName]) {
        counts[oldName] = 1;
      }
      counts[oldName] = counts[oldName] - 1;
    }
    if (!counts[name]) {
      counts[name] = 1;
    } else {
      counts[name] = counts[name] + 1;
    }

    const displayNameIsTaken = this.Encounter.Combatants().some(
      c => c.DisplayName() == this.DisplayName() && c != this
    );

    if (
      !this.IndexLabel() ||
      this.IndexLabel() < counts[name] ||
      displayNameIsTaken
    ) {
      this.IndexLabel(counts[name]);
    }

    this.Encounter.CombatantCountsByName(counts);
  }

  public InitiativeBonus = ko.computed(() => {
    const dexterityModifier = this.Encounter.Rules.GetModifierFromScore(
      this.StatBlock().Abilities.Dex
    );
    return dexterityModifier + (this.StatBlock().InitiativeModifier || 0);
  });

  public ConcentrationBonus = ko.computed(() =>
    this.Encounter.Rules.GetModifierFromScore(this.StatBlock().Abilities.Con)
  );

  public IsPlayerCharacter = ko.computed(() =>
    StatBlock.IsPlayerCharacter(this.StatBlock())
  );

  public MaxHP = ko.computed(() => this.StatBlock().HP.Value);

  public GetInitiativeRoll: () => number = () => {
    const sideInitiative =
      CurrentSettings().Rules.AutoGroupInitiative == "Side Initiative";

    let initiativeSpecialRoll: InitiativeSpecialRoll | undefined = undefined;
    if (!sideInitiative) {
      if (this.StatBlock().InitiativeAdvantage) {
        initiativeSpecialRoll = "advantage";
      }

      initiativeSpecialRoll = this.StatBlock().InitiativeSpecialRoll;
    }

    const initiativeBonus = sideInitiative ? 0 : this.InitiativeBonus();
    return this.Encounter.Rules.AbilityCheck(
      initiativeBonus,
      initiativeSpecialRoll
    );
  };

  public GetConcentrationRoll = () =>
    this.Encounter.Rules.AbilityCheck(this.ConcentrationBonus());

  public ApplyDamage(damage: number) {
    let currHP = this.CurrentHP(),
      tempHP = this.TemporaryHP();
    const allowNegativeHP = CurrentSettings().Rules.AllowNegativeHP;

    tempHP -= damage;
    if (tempHP < 0) {
      currHP += tempHP;
      tempHP = 0;
    }

    if (currHP <= 0 && !allowNegativeHP) {
      Metrics.TrackEvent("CombatantDefeated", { Name: this.DisplayName() });
      currHP = 0;
    }

    this.CurrentHP(currHP);
    this.TemporaryHP(tempHP);
    NotifyTutorialOfAction("ApplyDamage");
  }

  public ApplyHealing(healing: number) {
    let currHP = this.CurrentHP();

    currHP += healing;
    if (currHP > this.StatBlock().HP.Value) {
      currHP = this.StatBlock().HP.Value;
    }

    this.CurrentHP(currHP);
  }

  public ApplyTemporaryHP(tempHP: number) {
    if (tempHP > this.TemporaryHP()) {
      this.TemporaryHP(tempHP);
    }
  }

  public DisplayName = ko.pureComputed(() => {
    const alias = ko.unwrap(this.Alias),
      name = ko.unwrap(this.StatBlock).Name,
      combatantCount = this.Encounter.CombatantCountsByName()[name],
      index = this.IndexLabel();

    if (alias) {
      return alias;
    }
    if (combatantCount > 1) {
      return name + " " + index;
    }

    return name;
  });

  public GetState: () => CombatantState = () => {
    return {
      Id: this.Id,
      PersistentCharacterId: this.PersistentCharacterId || undefined,
      StatBlock: this.StatBlock(),
      CurrentHP: this.CurrentHP(),
      CurrentNotes: this.CurrentNotes(),
      TemporaryHP: this.TemporaryHP(),
      Initiative: this.Initiative(),
      InitiativeGroup: this.InitiativeGroup(),
      Alias: this.Alias(),
      IndexLabel: this.IndexLabel(),
      Tags: this.Tags()
        .filter(t => t.NotExpired())
        .map(t => t.GetState()),
      Hidden: this.Hidden(),
      RevealedAC: this.RevealedAC(),
      Color: this.Color(),
      ReactionsSpent: this.ReactionsSpent(),
      RoundCounter: this.CombatTimer.ElapsedRounds(),
      InterfaceVersion: process.env.VERSION || "unknown"
    };
  };

  private setAutoInitiativeGroup = () => {
    const autoInitiativeGroup = CurrentSettings().Rules.AutoGroupInitiative;
    let lowestInitiativeCombatant: Combatant | null = null;
    if (autoInitiativeGroup == "None") {
      return;
    }
    if (autoInitiativeGroup == "By Name") {
      if (this.IsPlayerCharacter()) {
        return;
      }
      lowestInitiativeCombatant = this.findLowestInitiativeGroupByName();
    } else if (autoInitiativeGroup == "Side Initiative") {
      lowestInitiativeCombatant = this.findLowestInitiativeGroupBySide();
    }

    if (lowestInitiativeCombatant) {
      if (!lowestInitiativeCombatant.InitiativeGroup()) {
        const initiativeGroup = probablyUniqueString();
        lowestInitiativeCombatant.InitiativeGroup(initiativeGroup);
      }

      this.Initiative(lowestInitiativeCombatant.Initiative());
      this.InitiativeGroup(lowestInitiativeCombatant.InitiativeGroup());
    }
  };

  private findLowestInitiativeGroupByName(): Combatant {
    const combatants = this.Encounter.Combatants();
    return combatants
      .filter(c => c != this)
      .filter(c => c.StatBlock().Name == this.StatBlock().Name)
      .sort((a, b) => a.Initiative() - b.Initiative())[0];
  }

  private findLowestInitiativeGroupBySide(): Combatant {
    const combatants = this.Encounter.Combatants();
    return combatants
      .filter(c => c != this)
      .filter(c => c.IsPlayerCharacter() === this.IsPlayerCharacter())
      .sort((a, b) => a.Initiative() - b.Initiative())[0];
  }
}


================================================
FILE: client/Combatant/CombatantDetails.tsx
================================================
import * as React from "react";

import { StatBlockComponent } from "../Components/StatBlock";
import { StatBlockHeader } from "../Components/StatBlockHeader";
import { TextEnricherContext } from "../TextEnricher/TextEnricher";
import { CombatantViewModel } from "./CombatantViewModel";
import { useSubscription } from "./linkComponentToObservables";
import { SettingsContext } from "../Settings/SettingsContext";
import { Tag } from "./Tag";
import { useContext } from "react";

interface CombatantDetailsProps {
  combatantViewModel: CombatantViewModel;
  displayMode: "default" | "active" | "status-only";
  key: string;
}

export function CombatantDetails(props: CombatantDetailsProps): JSX.Element {
  const TextEnricher = useContext(TextEnricherContext);
  const currentHp = useSubscription(props.combatantViewModel.HP);
  const currentHPPercentage = useSubscription(
    props.combatantViewModel.HPPercentage
  );
  const name = useSubscription(props.combatantViewModel.Name);
  const tags = useSubscription(props.combatantViewModel.Combatant.Tags);
  const notes = useSubscription(
    props.combatantViewModel.Combatant.CurrentNotes
  );
  const statBlock = useSubscription(
    props.combatantViewModel.Combatant.StatBlock
  );

  const { DisplayHPBar } = useContext(SettingsContext).TrackerView;
  if (!props.combatantViewModel) {
    return null;
  }

  const renderedNotes = notes.length
    ? TextEnricher.EnrichText(
        notes,
        props.combatantViewModel.Combatant.CurrentNotes
      )
    : null;

  return (
    <div className="c-combatant-details">
      <StatBlockHeader
        name={name}
        statBlockName={statBlock.Name}
        source={statBlock.Source}
        type={statBlock.Type}
        imageUrl={statBlock.ImageURL}
      />
      <div className="c-combatant-details__hp">
        <span className="stat-label">Current HP</span>
        <span>
          {currentHp}
          {DisplayHPBar && (
            <span className="combatant__hp-bar">
              <span
                className="combatant__hp-bar--filled"
                style={renderHPBarStyle(currentHPPercentage)}
              />
            </span>
          )}
        </span>
      </div>
      {tags.length > 0 && (
        <div className="c-combatant-details__tags">
          <span className="stat-label">Tags</span>{" "}
          <span className="stat-value">
            {tags.map((tag, index) => (
              <React.Fragment key={index}>
                <TagDetails tag={tag} />
              </React.Fragment>
            ))}
          </span>
        </div>
      )}
      {props.displayMode !== "status-only" && (
        <StatBlockComponent
          statBlock={statBlock}
          displayMode={props.displayMode}
          hideName
        />
      )}
      {renderedNotes && (
        <div className="c-combatant-details__notes">{renderedNotes}</div>
      )}
    </div>
  );
}

function TagDetails(props: { tag: Tag }) {
  const notExpired = useSubscription(props.tag.NotExpired);
  const durationRemaining = useSubscription(props.tag.DurationRemaining);
  if (!notExpired) {
    return null;
  }
  if (props.tag.HasDuration) {
    return (
      <span className="stat-value__item">
        {props.tag.Text} ({durationRemaining} more rounds)
      </span>
    );
  }

  return <span className="stat-value__item">{props.tag.Text}</span>;
}
function renderHPBarStyle(currentHPPercentage) {
  return { width: currentHPPercentage };
}


================================================
FILE: client/Combatant/CombatantViewModel.ts
================================================
import * as ko from "knockout";
import * as _ from "lodash";

import { CombatantCommander } from "../Commands/CombatantCommander";
import { ConcentrationTagText } from "../Prompts/ConcentrationPrompt";
import { CurrentSettings } from "../Settings/Settings";
import { Metrics } from "../Utility/Metrics";
import { Combatant } from "./Combatant";
import { Tag } from "./Tag";
import { TagState } from "../../common/CombatantState";
import { EditInitiativePrompt } from "../Prompts/EditInitiativePrompt";
import { PromptProps } from "../Prompts/PendingPrompts";
import { EditAliasPrompt } from "../Prompts/EditAliasPrompt";

const animatedCombatantIds = ko.observableArray<string>([]);

export class CombatantViewModel {
  public HP: ko.PureComputed<string>;
  public HPPercentage: ko.PureComputed<string>;
  public Name: ko.PureComputed<string>;

  constructor(
    public Combatant: Combatant,
    public CombatantCommander: CombatantCommander,
    public EnqueuePrompt: (prompt: PromptProps<any>) => void,
    public LogEvent: (message: string) => void
  ) {
    this.HP = ko.pureComputed(() => {
      if (this.Combatant.TemporaryHP()) {
        return `${this.Combatant.CurrentHP()}+${this.Combatant.TemporaryHP()}/${this.Combatant.MaxHP()}`;
      } else {
        return `${this.Combatant.CurrentHP()}/${this.Combatant.MaxHP()}`;
      }
    });
    this.HPPercentage = ko.pureComputed(() => {
      return (
        Math.floor(
          (this.Combatant.CurrentHP() / this.Combatant.MaxHP()) * 100
        ) + "%"
      );
    });
    this.Name = Combatant.DisplayName;
    setTimeout(() => animatedCombatantIds.push(this.Combatant.Id), 500);
  }

  public ApplyDamage(inputDamage: string) {
    const damage = parseInt(inputDamage),
      healing = -damage,
      shouldAutoCheckConcentration =
        CurrentSettings().Rules.AutoCheckConcentration;

    if (isNaN(damage)) {
      return;
    }

    if (damage > 0) {
      Metrics.TrackEvent("DamageApplied", { Amount: damage.toString() });
      if (
        shouldAutoCheckConcentration &&
        this.Combatant.Tags().some(t => t.Text === ConcentrationTagText)
      ) {
        this.CombatantCommander.CheckConcentration(this.Combatant, damage);
      }
      this.Combatant.ApplyDamage(damage);
    } else {
      this.Combatant.ApplyHealing(healing);
    }
  }

  public ApplyTemporaryHP(newTemporaryHP: number) {
    if (isNaN(newTemporaryHP)) {
      return;
    }

    this.Combatant.ApplyTemporaryHP(newTemporaryHP);
  }

  public ApplyInitiative(initiative: number) {
    this.Combatant.Initiative(initiative);
    this.Combatant.Encounter.SortByInitiative(true);
  }

  public EditInitiative() {
    const prompt = EditInitiativePrompt(this.Combatant, model => {
      if (model.initiativeRoll) {
        if (model.breakLink) {
          this.Combatant.InitiativeGroup(null);
          this.Combatant.Encounter.CleanInitiativeGroups();
        }
        this.ApplyInitiative(model.initiativeRoll);
        this.LogEvent(
          `${this.Name()} initiative set to ${model.initiativeRoll}.`
        );
        Metrics.TrackEvent("InitiativeSet", { Name: this.Name() });
        return true;
      }
      return false;
    });

    this.EnqueuePrompt(prompt);
  }

  public SetAlias() {
    const currentName = this.Combatant.DisplayName();
    const prompt = EditAliasPrompt(this.Combatant, model => {
      this.Combatant.Alias(model.alias);
      if (model.alias) {
        this.LogEvent(`${currentName} alias changed to ${model.alias}.`);
        Metrics.TrackEvent("AliasSet", {
          StatBlockName: this.Combatant.StatBlock().Name,
          Alias: model.alias
        });
      } else {
        this.LogEvent(`${currentName} alias removed.`);
      }
      return true;
    });
    this.EnqueuePrompt(prompt);
  }

  public ToggleSpentReaction(): void {
    if (this.Combatant.ReactionsSpent() == 0) {
      this.Combatant.ReactionsSpent(1);
    } else {
      this.Combatant.ReactionsSpent(0);
    }
  }

  public ToggleHidden() {
    if (this.Combatant.Hidden()) {
      this.Combatant.Hidden(false);
      this.LogEvent(`${this.Name()} revealed in player view.`);
      Metrics.TrackEvent("CombatantRevealed", {
        Name: this.Name()
      });
    } else {
      this.Combatant.Hidden(true);
      this.LogEvent(`${this.Name()} hidden in player view.`);
      Metrics.TrackEvent("CombatantHidden", {
        Name: this.Name()
      });
    }
  }

  public ToggleRevealedAC() {
    if (this.Combatant.RevealedAC()) {
      this.Combatant.RevealedAC(false);
      this.LogEvent(`${this.Name()} AC hidden in player view.`);
      Metrics.TrackEvent("CombatantACHidden", {
        Name: this.Name()
      });
    } else {
      this.Combatant.RevealedAC(true);
      this.LogEvent(`${this.Name()} AC revealed in player view.`);
      Metrics.TrackEvent("CombatantACRevealed", {
        Name: this.Name()
      });
    }
  }

  public RemoveTag = (tag: Tag) => {
    this.Combatant.Tags.splice(this.Combatant.Tags.indexOf(tag), 1);
    this.LogEvent(`${this.Name()} removed tag: "${tag.Text}"`);
  };

  public RemoveTagByState = (tagState: TagState) => {
    const tag = this.Combatant.Tags().find(t =>
      _.isEqual(tagState, t.GetState())
    );
    if (tag !== undefined) {
      this.Combatant.Tags.remove(tag);
    }
  };
}


================================================
FILE: client/Combatant/GetOrRollMaximumHP.test.ts
================================================
import { StatBlock } from "../../common/StatBlock";
import { InitializeTestSettings } from "../test/InitializeTestSettings";
import { GetOrRollMaximumHP, VariantMaximumHP } from "./GetOrRollMaximumHP";

describe("GetOrRollMaximumHP", () => {
  let statBlock: StatBlock;

  beforeEach(() => {
    InitializeTestSettings();
    statBlock = StatBlock.Default();
    statBlock.HP = {
      Value: 12, // Lower than the minimum to test rolling dice vs. using value
      Notes: "8d10 + 16" // Average: 40 | Minimum: 24 | Maximum: 96
    };
  });

  test("Should use stat block's HP value by default", () => {
    const hp = GetOrRollMaximumHP(statBlock, VariantMaximumHP.DEFAULT);
    expect(hp).toEqual(12);
  });

  test("Should roll stat block's HP if setting is enabled", () => {
    InitializeTestSettings({
      Rules: {
        RollMonsterHp: true
      }
    });

    const hp = GetOrRollMaximumHP(statBlock, VariantMaximumHP.DEFAULT);
    expect(hp).toBeGreaterThanOrEqual(24);
    expect(hp).toBeLessThanOrEqual(96);
  });

  test("Should return 1 HP for VariantMaximumHP.MINION", () => {
    const hp = GetOrRollMaximumHP(statBlock, VariantMaximumHP.MINION);
    expect(hp).toEqual(1);
  });

  test("Should return max rolled HP for VariantMaximumHP.BOSS", () => {
    const hp = GetOrRollMaximumHP(statBlock, VariantMaximumHP.BOSS);
    expect(hp).toEqual(96);
  });
});


================================================
FILE: client/Combatant/GetOrRollMaximumHP.ts
================================================
import { StatBlock } from "../../common/StatBlock";
import { Dice } from "../Rules/Dice";
import { CurrentSettings } from "../Settings/Settings";

export enum VariantMaximumHP {
  DEFAULT,
  MINION,
  BOSS
}

export function GetOrRollMaximumHP(
  statBlock: StatBlock,
  variant: VariantMaximumHP
) {
  const rollMonsterHp = CurrentSettings().Rules.RollMonsterHp;
  if (statBlock.Player !== "player") {
    if (variant == VariantMaximumHP.MINION) {
      return 1;
    } else if (variant == VariantMaximumHP.BOSS || rollMonsterHp) {
      try {
        const hpResult = Dice.RollDiceExpression(statBlock.HP.Notes);
        if (variant == VariantMaximumHP.BOSS) {
          return hpResult.Maximum;
        }
        const rolledHP = hpResult.Total;
        if (rolledHP > 0) {
          return rolledHP;
        }
        return 1;
      } catch (e) {
        console.error(e);
        return statBlock.HP.Value;
      }
    }
  }
  return statBlock.HP.Value;
}


================================================
FILE: client/Combatant/IndexLabeling.test.ts
================================================
import { StatBlock } from "../../common/StatBlock";
import { Encounter } from "../Encounter/Encounter";
import { InitializeTestSettings } from "../test/InitializeTestSettings";
import { buildEncounter } from "../test/buildEncounter";

describe("Index labeling", () => {
  let encounter: Encounter;
  beforeEach(() => {
    InitializeTestSettings();
    encounter = buildEncounter();
  });

  test("A lone combatant is not index labelled.", () => {
    const statBlock = { ...StatBlock.Default(), Name: "Goblin" };
    const combatant1 = encounter.AddCombatantFromStatBlock(statBlock);
    expect(combatant1.DisplayName()).toEqual("Goblin");
  });

  test("When multiple combatants are added with the same name, they should display index labels.", () => {
    const statBlock = { ...StatBlock.Default(), Name: "Goblin" };

    const combatant1 = encounter.AddCombatantFromStatBlock(statBlock);
    const combatant2 = encounter.AddCombatantFromStatBlock(statBlock);
    expect(combatant1.DisplayName()).toEqual("Goblin 1");
    expect(combatant2.DisplayName()).toEqual("Goblin 2");
  });

  test("When a combatant statblock name changes, it receives an index label if necessary", () => {
    const statBlock1 = { ...StatBlock.Default(), Name: "Goblin" };
    const statBlock2 = { ...StatBlock.Default(), Name: "Not Goblin" };

    const combatant1 = encounter.AddCombatantFromStatBlock(statBlock1);
    const combatant2 = encounter.AddCombatantFromStatBlock(statBlock2);
    combatant2.StatBlock(statBlock1);

    expect(combatant1.DisplayName()).toEqual("Goblin 1");
    expect(combatant2.DisplayName()).toEqual("Goblin 2");
  });

  test("When all combatants the same name are removed, index labelling should reset.", () => {
    const statBlock = { ...StatBlock.Default(), Name: "Goblin" };

    const combatant1 = encounter.AddCombatantFromStatBlock(statBlock);
    const combatant2 = encounter.AddCombatantFromStatBlock(statBlock);
    encounter.RemoveCombatant(combatant1);
    encounter.RemoveCombatant(combatant2);

    const newCombatant1 = encounter.AddCombatantFromStatBlock(statBlock);

    expect(newCombatant1.DisplayName()).toEqual("Goblin");

    const newCombatant2 = encounter.AddCombatantFromStatBlock(statBlock);

    expect(newCombatant1.DisplayName()).toEqual("Goblin 1");
    expect(newCombatant2.DisplayName()).toEqual("Goblin 2");
  });

  test("When a labelled combatant is removed, its index label is not reused.", () => {
    const statBlock = { ...StatBlock.Default(), Name: "Goblin" };

    const combatant1 = encounter.AddCombatantFromStatBlock(statBlock);
    const combatant2 = encounter.AddCombatantFromStatBlock(statBlock);
    encounter.RemoveCombatant(combatant2);

    const combatant3 = encounter.AddCombatantFromStatBlock(statBlock);

    expect(combatant1.DisplayName()).toEqual("Goblin 1");
    expect(combatant2.DisplayName()).toEqual("Goblin 2");
    expect(combatant3.DisplayName()).toEqual("Goblin 3");
  });

  function buildEncounterState() {
    const statBlock = { ...StatBlock.Default(), Name: "Goblin" };
    const oldEncounter = buildEncounter();
    for (const initiative of [8, 10]) {
      const combatant = oldEncounter.AddCombatantFromStatBlock(statBlock);
      combatant.Initiative(initiative);
    }
    oldEncounter.EncounterFlow.StartEncounter();
    const savedEncounter = oldEncounter.ObservableEncounterState();
    return savedEncounter;
  }

  test("When a saved encounter state is loaded, it keeps the correct index labels", () => {
    const encounterState = buildEncounterState();
    const newEncounter = buildEncounter();
    newEncounter.LoadEncounterState(encounterState, () => {}, null);

    const combatantDisplayNames = newEncounter
      .Combatants()
      .map(c => c.DisplayName());
    expect(combatantDisplayNames).toContain("Goblin 2");
    expect(combatantDisplayNames).toContain("Goblin 1");
  });
});


================================================
FILE: client/Combatant/MultipleCombatantDetails.tsx
================================================
import * as React from "react";
import { TextEnricher } from "../TextEnricher/TextEnricher";
import { CombatantDetails } from "./CombatantDetails";
import { CombatantViewModel } from "./CombatantViewModel";

interface MultipleCombatantDetailsProps {
  combatants: CombatantViewModel[];
}

export class MultipleCombatantDetails extends React.Component<MultipleCombatantDetailsProps> {
  public render() {
    return (
      <div className="c-multiple-combatant-details">
        {this.props.combatants.map(c => (
          <CombatantDetails
            combatantViewModel={c}
            displayMode="status-only"
            key={c.Combatant.Id}
          />
        ))}
      </div>
    );
  }
}


================================================
FILE: client/Combatant/Tag.ts
================================================
import * as ko from "knockout";

import { TagState } from "../../common/CombatantState";
import { DurationTiming } from "../../common/DurationTiming";
import { Combatant } from "./Combatant";

export const StartOfTurn: DurationTiming = "StartOfTurn";
export const EndOfTurn: DurationTiming = "EndOfTurn";

export interface Tag {
  Text: string;
  HasDuration: boolean;
  DurationRemaining: KnockoutObservable<number>;
  DurationTiming: DurationTiming;
  DurationCombatantId: string;
  NotExpired: ko.PureComputed<boolean>;
  Remove: () => void;
  Decrement: () => void;
  Increment: () => void;
}

export class Tag implements Tag {
  constructor(
    public Text: string,
    combatant: Combatant,
    public HiddenFromPlayerView: boolean,
    duration = -1,
    public DurationTiming = StartOfTurn,
    public DurationCombatantId = ""
  ) {
    this.HasDuration = duration > -1;
    this.DurationRemaining = ko.observable(duration);
    this.Remove = () => combatant.Tags.remove(this);
  }

  public GetState = ko.pureComputed<TagState>(() => {
    return {
      Text: this.Text,
      Hidden: this.HiddenFromPlayerView,
      DurationRemaining: this.DurationRemaining(),
      DurationTiming: this.DurationTiming,
      DurationCombatantId: this.DurationCombatantId
    };
  });

  public Decrement = () => this.DurationRemaining(this.DurationRemaining() - 1);

  public Increment = () => this.DurationRemaining(this.DurationRemaining() + 1);

  public NotExpired = ko.pureComputed(() => {
    return !this.HasDuration || this.DurationRemaining() > 0;
  });

  public static FromTagStates = (
    tags: TagState[],
    combatant: Combatant
  ): Tag[] => {
    return tags.map(
      tag =>
        new Tag(
          tag.Text,
          combatant,
          tag.Hidden || false,
          tag.DurationRemaining,
          tag.DurationTiming,
          tag.DurationCombatantId
        )
    );
  };
}


================================================
FILE: client/Combatant/ToPlayerViewCombatantState.ts
================================================
import { PlayerViewCombatantState } from "../../common/PlayerViewCombatantState";
import { env } from "../Environment";
import { CurrentSettings } from "../Settings/Settings";
import { Combatant } from "./Combatant";

export function ToPlayerViewCombatantState(
  combatant: Combatant
): PlayerViewCombatantState {
  const sendImage = env.HasEpicInitiative;
  return {
    Name: combatant.DisplayName(),
    Id: combatant.Id,
    HPDisplay: GetHPDisplay(combatant),
    HPColor: GetHPColor(combatant),
    Initiative: combatant.Initiative(),
    IsPlayerCharacter: combatant.IsPlayerCharacter(),
    Tags: combatant
      .Tags()
      .filter(t => t.NotExpired() && !t.HiddenFromPlayerView)
      .map(t => {
        return {
          Text: t.Text,
          DurationRemaining: t.DurationRemaining(),
          DurationTiming: t.DurationTiming,
          DurationCombatantId: t.DurationCombatantId
        };
      }),
    ImageURL: sendImage ? combatant.StatBlock().ImageURL : "",
    AC: combatant.RevealedAC() ? combatant.StatBlock().AC.Value : undefined,
    Color: combatant.Color(),
    ReactionsSpent: combatant.ReactionsSpent()
  };
}

function GetHPDisplay(combatant: Combatant): string {
  const hpVerbosity = combatant.IsPlayerCharacter()
    ? CurrentSettings().PlayerView.PlayerHPVerbosity
    : CurrentSettings().PlayerView.MonsterHPVerbosity;
  const maxHP = combatant.MaxHP(),
    currentHP = combatant.CurrentHP(),
    temporaryHP = combatant.TemporaryHP();
  if (hpVerbosity == "Actual HP") {
    if (temporaryHP) {
      return `${currentHP}+${temporaryHP}/${maxHP}`;
    } else {
      return `${currentHP}/${maxHP}`;
    }
  }
  if (hpVerbosity == "Hide All") {
    return "";
  }
  if (hpVerbosity == "Damage Taken") {
    return (currentHP - maxHP).toString();
  }
  if (currentHP <= 0) {
    return "<span class='defeatedHP'>Defeated</span>";
  } else if (currentHP < maxHP / 2) {
    return "<span class='bloodiedHP'>Bloodied</span>";
  } else if (currentHP < maxHP) {
    return "<span class='hurtHP'>Hurt</span>";
  }
  return "<span class='healthyHP'>Healthy</span>";
}

function GetHPColor(combatant: Combatant) {
  const maxHP = combatant.MaxHP(),
    currentHP = combatant.CurrentHP();
  const hpVerbosity = combatant.IsPlayerCharacter()
    ? CurrentSettings().PlayerView.PlayerHPVerbosity
    : CurrentSettings().PlayerView.MonsterHPVerbosity;
  if (
    hpVerbosity == "Monochrome Label" ||
    hpVerbosity == "Hide All" ||
    hpVerbosity == "Damage Taken"
  ) {
    return "auto";
  }
  const green = Math.floor((currentHP / maxHP) * 170);
  const red = Math.floor(((maxHP - currentHP) / maxHP) * 170);
  return "rgb(" + red + "," + green + ",0)";
}


================================================
FILE: client/Combatant/linkComponentToObservables.tsx
================================================
import * as React from "react";
import * as ko from "knockout";
import { noop } from "lodash";

export function linkComponentToObservables(component: React.Component) {
  let observableSubscription = ko.observable().subscribe(noop);
  const oldComponentDidMount = component.componentDidMount || noop;
  component.componentDidMount = () => {
    observableSubscription = ko
      .computed(() => component.render())
      .subscribe(() => component.forceUpdate());
    oldComponentDidMount();
  };
  const oldComponentWillUnmount = component.componentWillUnmount || noop;
  component.componentWillUnmount = () => {
    observableSubscription.dispose();
    oldComponentWillUnmount();
  };
}

export function useSubscription<T>(observable: KnockoutObservable<T>): T {
  const [value, setValue] = React.useState({ current: observable() });

  React.useEffect(() => {
    //If the observable itself changed, we want to get its current value as part of this useEffect.
    setValue({ current: observable() });

    const subscription = observable.subscribe(newValue => {
      // In case newValue is a reference to the same object as before
      // such as with KnockoutObservableArray, we instantiate a wrapper
      // object for setValue.
      setValue({ current: newValue });
    });
    return () => subscription.dispose();
  }, [observable]);

  return value.current;
}


================================================
FILE: client/Commands/BuildCombatantCommandList.ts
================================================
import { CombatantCommander } from "./CombatantCommander";
import { Command } from "./Command";

export const BuildCombatantCommandList: (
  c: CombatantCommander
) => Command[] = c => [
  new Command({
    id: "apply-damage",
    description: "Apply Damage",
    actionBinding: c.ApplyDamage,
    fontAwesomeIcon: "fist-raised"
  }),
  new Command({
    id: "apply-healing",
    description: "Apply Healing",
    actionBinding: c.ApplyHealing,
    fontAwesomeIcon: "heart",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "apply-temporary-hp",
    description: "Apply Temporary HP",
    actionBinding: c.AddTemporaryHP,
    fontAwesomeIcon: "medkit",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "add-tag",
    description: "Add Tag",
    actionBinding: c.AddTag,
    fontAwesomeIcon: "tag",
    defaultShowOnActionBar: false,
    defaultShowInCombatantRow: true
  }),
  new Command({
    id: "update-notes",
    description: "Update Persistent Notes",
    actionBinding: c.UpdateNotes,
    fontAwesomeIcon: "file-alt",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "remove",
    description: "Remove from Encounter",
    actionBinding: c.Remove,
    fontAwesomeIcon: "times"
  }),
  new Command({
    id: "set-alias",
    description: "Rename",
    actionBinding: c.SetAlias,
    fontAwesomeIcon: "i-cursor"
  }),
  new Command({
    id: "toggle-reaction",
    description: "Toggle Spent Reaction",
    actionBinding: c.ToggleSpentReaction,
    fontAwesomeIcon: "reply",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "toggle-hidden",
    description: "Hide/Reveal in Player View",
    actionBinding: c.ToggleHidden,
    fontAwesomeIcon: "eye",
    defaultShowOnActionBar: false,
    defaultShowInCombatantRow: true
  }),
  new Command({
    id: "toggle-reveal-ac",
    description: "Reveal/Hide AC in Player View",
    actionBinding: c.ToggleRevealedAC,
    fontAwesomeIcon: "shield-alt",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "edit-statblock",
    description: "Edit Unique Statblock",
    actionBinding: c.EditOwnStatBlock,
    fontAwesomeIcon: "edit",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "quick-edit-statblock",
    description: "Quick Edit Combatant",
    actionBinding: c.QuickEditOwnStatBlock,
    fontAwesomeIcon: "magic",
    defaultShowOnActionBar: true
  }),
  new Command({
    id: "set-initiative",
    description: "Edit Initiative",
    actionBinding: c.EditInitiative,
    fontAwesomeIcon: "stopwatch",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "link-initiative",
    description: "Link Initiative",
    actionBinding: c.LinkInitiative,
    fontAwesomeIcon: "link",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "move-down",
    description: "Move Down",
    actionBinding: c.MoveDown,
    fontAwesomeIcon: "angle-double-down"
  }),
  new Command({
    id: "move-up",
    description: "Move Up",
    actionBinding: c.MoveUp,
    fontAwesomeIcon: "angle-double-up"
  }),
  new Command({
    id: "select-next",
    description: "Select Next",
    actionBinding: c.SelectNext,
    fontAwesomeIcon: "arrow-down",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "select-previous",
    description: "Select Previous",
    actionBinding: c.SelectPrevious,
    fontAwesomeIcon: "arrow-up",
    defaultShowOnActionBar: false
  }),
  new Command({
    id: "duplicate-combatant",
    description: "Duplicate Combatant",
    actionBinding: c.Duplicate,
    fontAwesomeIcon: "clone",
    defaultShowOnActionBar: false
  })
];


================================================
FILE: client/Commands/BuildEncounterCommandList.ts
================================================
import { Command } from "./Command";
import { EncounterCommander } from "./EncounterCommander";

export const BuildEncounterCommandList = (
  c: EncounterCommander,
  saveEncounterFn: () => void
): Command[] =>
  [
    new Command({
      id: "toggle-menu",
      description: "Toggle Wide Menu",
      actionBinding: c.ToggleToolbarWidth,
      fontAwesomeIcon: "chevron-right",
      lockOnActionBar: true
    }),
    new Command({
      id: "start-encounter",
      description: "Start Encounter",
      actionBinding: c.StartEncounter,
      fontAwesomeIcon: "play"
    }),
    new Command({
      id: "reroll-initiative",
      description: "Reroll Initiative",
      actionBinding: c.RerollInitiative,
      fontAwesomeIcon: "sync",
      defaultShowOnActionBar: false
    }),
    new Command({
      id: "end-encounter",
      description: "End Encounter",
      actionBinding: c.EndEncounter,
      fontAwesomeIcon: "stop"
    }),
    new Command({
      id: "clear-encounter",
      description: "Clear Encounter",
      actionBinding: c.ClearEncounter,
      fontAwesomeIcon: "trash",
      defaultShowOnActionBar: false
    }),
    new Command({
      id: "clean-encounter",
      description: "Clean Encounter",
      actionBinding: c.CleanEncounter,
      fontAwesomeIcon: "broom"
    }),
    new Command({
      id: "open-library",
      description: "Library Reference Pane",
      actionBinding: c.ShowLibraries,
      fontAwesomeIcon: "book-medical"
    }),
    new Command({
      id: "open-library-manager",
      description: "Library Manager",
      actionBinding: c.ToggleLibraryManager,
      fontAwesomeIcon: "book-open",
      defaultShowOnActionBar: false
    }),
    new Command({
      id: "roll-dice",
      description: "Roll Dice",
      actionBinding: c.PromptRollDice,
      fontAwesomeIcon: "dice",
      defaultShowOnActionBar: false
    }),
    new Command({
      id: "quick-add",
      description: "Quick Add Combatant",
      actionBinding: c.QuickAddStatBlock,
      fontAwesomeIcon: "bolt",
      defaultShowOnActionBar: false
    }),
    new Command({
      id: "restore-all-player-character-hp",
      description: "Restore all Player Character HP",
      actionBinding: c.RestoreAllPlayerCharacterHP,
      fontAwesomeIcon: "clinic-medical",
      defaultShowOnActionBar: false
    }),
    new Command({
      id: "player-window",
      description: "Launch Player View",
      actionBinding: c.LaunchPlayerView,
      fontAwesomeIcon: "users"
    }),
    typeof document.documentElement.requestFullscreen == "function" &&
      new Command({
        id: "toggle-full-screen",
        description: "Toggle Full Screen",
        actionBinding: c.ToggleFullScreen,
        fontAwesomeIcon: "expand",
        defaultShowOnActionBar: false
      }),
    new Command({
      id: "next-turn",
      description: "Next Turn",
      actionBinding: c.NextTurn,
      fontAwesomeIcon: "step-forward"
    }),
    new Command({
      id: "previous-turn",
      description: "Previous Turn",
      actionBinding: c.PreviousTurn,
      fontAwesomeIcon: "step-backward",
      defaultShowOnActionBar: false
    }),
    new Command({
      id: "save-encounter",
      description: "Save Encounter",
      actionBinding: saveEncounterFn,
      fontAwesomeIcon: "save"
    }),
    new Command({
      id: "settings",
      description: "Settings",
      actionBinding: c.ShowSettings,
      fontAwesomeIcon: "cog",
      lockOnActionBar: true
    })
  ].filter(c => c) as Command[];


================================================
FILE: client/Commands/CombatantCommander.test.ts
================================================
import { StatBlock } from "../../common/StatBlock";
import { Encounter } from "../Encounter/Encounter";
import { InitializeTestSettings } from "../test/InitializeTestSettings";
import { TrackerViewModel } from "../TrackerViewModel";
import { CombatantCommander } from "./CombatantCommander";

describe("CombatantCommander", () => {
  let encounter: Encounter;
  let combatantCommander: CombatantCommander;
  let trackerViewModel: TrackerViewModel;
  beforeEach(() => {
    window.confirm = () => true;

    InitializeTestSettings();

    const mockIo: any = {
      on: jest.fn(),
      emit: jest.fn()
    };

    trackerViewModel = new TrackerViewModel(mockIo);
    encounter = trackerViewModel.Encounter;
    combatantCommander = trackerViewModel.CombatantCommander;
  });

  afterEach(() => {
    encounter.ClearEncounter();
  });

  test("Apply Damage", () => {
    encounter.AddCombatantFromStatBlock({
      ...StatBlock.Default(),
      HP: { Value: 10 }
    });
    const combatantViewModel = trackerViewModel.CombatantViewModels()[0];
    expect(combatantViewModel.HP()).toEqual("10/10");
    combatantViewModel.ApplyDamage("5");
    expect(combatantViewModel.HP()).toEqual("5/10");
  });

  test("Toggle Hidden", () => {
    encounter.AddCombatantFromStatBlock(StatBlock.Default());
    const combatantViewModel = trackerViewModel.CombatantViewModels()[0];

    const playerViewBeforeToggle = encounter.GetPlayerView();
    expect(playerViewBeforeToggle.Combatants).toHaveLength(1);

    combatantCommander.Select(combatantViewModel);
    combatantCommander.ToggleHidden();
    const playerView = encounter.GetPlayerView();

    expect(playerView.Combatants).toHaveLength(0);
  });

  test("Toggle Reveal AC", () => {
    encounter.AddCombatantFromStatBlock(StatBlock.Default());
    const combatantViewModel = trackerViewModel.CombatantViewModels()[0];

    const playerViewBeforeToggle = encounter.GetPlayerView();
    expect(playerViewBeforeToggle.Combatants[0].AC).toBeUndefined();

    combatantCommander.Select(combatantViewModel);
    combatantCommander.ToggleRevealedAC();
    const playerView = encounter.GetPlayerView();

    expect(playerView.Combatants[0].AC).toBe(10);
  });

  test("Should maintain selection when initiative order changes", () => {
    const combatant1 = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    const combatant2 = encounter.AddCombatantFromStatBlock(StatBlock.Default());

    combatant1.Initiative(15);
    combatant2.Initiative(10);
    encounter.SortByInitiative(false);

    expect(trackerViewModel.CombatantViewModels()[0].Combatant).toBe(
      combatant1
    );

    const combatantViewModel = trackerViewModel.CombatantViewModels()[0];
    expect(combatantViewModel.Combatant).toBe(combatant1);

    combatantCommander.Select(combatantViewModel);
    combatantViewModel.ApplyInitiative(5);

    expect(trackerViewModel.CombatantViewModels()[1].Combatant).toBe(
      combatant1
    );

    expect(combatantCommander.SelectedCombatants()[0]).toBe(
      trackerViewModel.CombatantViewModels()[1]
    );
  });
});


================================================
FILE: client/Commands/CombatantCommander.tsx
================================================
import * as ko from "knockout";
import * as React from "react";

import { CombatantState, TagState } from "../../common/CombatantState";
import { probablyUniqueString } from "../../common/Toolbox";
import { Combatant } from "../Combatant/Combatant";
import { CombatantDetails } from "../Combatant/CombatantDetails";
import { CombatantViewModel } from "../Combatant/CombatantViewModel";
import { MultipleCombatantDetails } from "../Combatant/MultipleCombatantDetails";
import { Dice } from "../Rules/Dice";
import { RollResult } from "../Rules/RollResult";
import { CurrentSettings } from "../Settings/Settings";
import { TrackerViewModel } from "../TrackerViewModel";
import { Metrics } from "../Utility/Metrics";
import { BuildCombatantCommandList } from "./BuildCombatantCommandList";
import { Command } from "./Command";
import { AcceptDamagePrompt } from "../Prompts/AcceptDamagePrompt";
import { AcceptTagPrompt } from "../Prompts/AcceptTagPrompt";
import { ApplyDamagePrompt } from "../Prompts/ApplyDamagePrompt";
import { ApplyHealingPrompt } from "../Prompts/ApplyHealingPrompt";
import { ConcentrationPrompt } from "../Prompts/ConcentrationPrompt";
import { ShowDiceRollPrompt } from "../Prompts/RollDicePrompt";
import { TagPrompt } from "../Prompts/TagPrompt";
import { UpdateNotesPrompt } from "../Prompts/UpdateNotesPrompt";
import { ApplyTemporaryHPPrompt } from "../Prompts/ApplyTemporaryHPPrompt";
import { LinkInitiativePrompt } from "../Prompts/LinkInitiativePrompt";
import { TextEnricherContext } from "../TextEnricher/TextEnricher";
import { QuickEditStatBlockPrompt } from "../Prompts/QuickEditStatBlockPrompt";

interface PendingLinkInitiative {
  combatant: CombatantViewModel;
  promptId: string;
}

export class CombatantCommander {
  private selectedCombatantIds = ko.observableArray<string>([]);
  private latestRoll: RollResult;

  constructor(private tracker: TrackerViewModel) {
    this.Commands = BuildCombatantCommandList(this);
  }

  public Commands: Command[];
  public SelectedCombatants = ko.pureComputed<CombatantViewModel[]>(() => {
    const selectedCombatantIds = this.selectedCombatantIds();
    return this.tracker
      .CombatantViewModels()
      .filter(c => selectedCombatantIds.some(id => c.Combatant.Id == id));
  });
  public HasSelected = ko.pureComputed(
    () => this.SelectedCombatants().length > 0
  );
  public HasOneSelected = ko.pureComputed(
    () => this.SelectedCombatants().length === 1
  );
  public HasMultipleSelected = ko.pureComputed(
    () => this.SelectedCombatants().length > 1
  );

  public CombatantDetails = ko.pureComputed(() => {
    const selectedCombatants = this.SelectedCombatants();
    if (!this.HasSelected()) {
      return null;
    }

    if (this.HasOneSelected()) {
      const combatantViewModel = selectedCombatants[0];
      return (
        <TextEnricherContext.Provider
          value={this.tracker.StatBlockTextEnricher}
        >
          <CombatantDetails
            combatantViewModel={combatantViewModel}
            displayMode="default"
            key={combatantViewModel.Combatant.Id}
          />
        </TextEnricherContext.Provider>
      );
    } else {
      return (
        <TextEnricherContext.Provider
          value={this.tracker.StatBlockTextEnricher}
        >
          <MultipleCombatantDetails combatants={selectedCombatants} />
        </TextEnricherContext.Provider>
      );
    }
  });

  public Select = (data: CombatantViewModel, appendSelection?: boolean) => {
    if (!data) {
      return;
    }
    const pendingLink = this.pendingLinkInitiative();
    if (pendingLink) {
      this.linkCombatantInitiatives([data, pendingLink.combatant]);
      this.tracker.PromptQueue.Remove(pendingLink.promptId);
    }

    const combatantsToRemainSelected = appendSelection
      ? this.selectedCombatantIds()
      : [];

    const allSelected = [...combatantsToRemainSelected, data.Combatant.Id];

    this.selectedCombatantIds(allSelected);

    Metrics.TrackEvent("CombatantsSelected", {
      Count: this.selectedCombatantIds().length
    });
  };

  private selectByOffset = (offset: number) => {
    let newIndex =
      this.tracker.CombatantViewModels().indexOf(this.SelectedCombatants()[0]) +
      offset;
    if (newIndex < 0) {
      newIndex = 0;
    } else if (newIndex >= this.tracker.CombatantViewModels().length) {
      newIndex = this.tracker.CombatantViewModels().length - 1;
    }
    this.selectedCombatantIds.removeAll();
    this.selectedCombatantIds.push(
      this.tracker.CombatantViewModels()[newIndex].Combatant.Id
    );
  };

  public Remove = async () => {
    if (!this.HasSelected()) {
      return;
    }

    const combatantsToRemove = this.SelectedCombatants();
    this.selectedCombatantIds.removeAll();
    const firstDeletedIndex = this.tracker
      .CombatantViewModels()
      .indexOf(combatantsToRemove[0]);
    const deletedCombatantNames = combatantsToRemove.map(
      c => c.Combatant.StatBlock().Name
    );

    if (this.tracker.CombatantViewModels().length > combatantsToRemove.length) {
      let activeCombatant =
        this.tracker.Encounter.EncounterFlow.ActiveCombatant();
      while (combatantsToRemove.some(c => c.Combatant === activeCombatant)) {
        await this.tracker.Encounter.EncounterFlow.NextTurn(
          this.tracker.EncounterCommander.RerollInitiative
        );
        activeCombatant =
          this.tracker.Encounter.EncounterFlow.ActiveCombatant();
      }
    }

    combatantsToRemove.forEach(vm =>
      this.tracker.Encounter.RemoveCombatant(vm.Combatant)
    );

    const remainingCombatants = this.tracker.CombatantViewModels();
    if (remainingCombatants.length > 0) {
      const newSelectionIndex =
        firstDeletedIndex > remainingCombatants.length
          ? remainingCombatants.length - 1
          : firstDeletedIndex;
      this.Select(this.tracker.CombatantViewModels()[newSelectionIndex]);
    }

    this.tracker.EventLog.AddEvent(
      `${deletedCombatantNames.join(", ")} removed from encounter.`
    );
    Metrics.TrackEvent("CombatantsRemoved", { Names: deletedCombatantNames });
  };

  public FlushCombatants = () => {
    this.tracker.Encounter.FlushCombatants();
  };

  public RestoreCombatants = () => {
    this.tracker.Encounter.RestoreCombatants();
  };

  public Deselect = () => {
    this.selectedCombatantIds([]);
  };

  public SelectPrevious = () => {
    if (this.tracker.CombatantViewModels().length == 0) {
      return;
    }

    if (!this.HasSelected()) {
      this.Select(this.tracker.CombatantViewModels()[0]);
      return;
    }

    this.selectByOffset(-1);
  };

  public SelectNext = () => {
    if (this.tracker.CombatantViewModels().length == 0) {
      return;
    }

    if (!this.HasSelected()) {
      this.Select(this.tracker.CombatantViewModels()[0]);
      return;
    }

    this.selectByOffset(1);
  };

  private applyDamageForCombatants(combatantViewModels: CombatantViewModel[]) {
    const latestRollTotal = this.latestRoll?.Total || 0;
    const prompt = ApplyDamagePrompt(
      combatantViewModels,
      latestRollTotal.toString(),
      this.tracker.EventLog.LogHPChange
    );
    this.tracker.PromptQueue.Add(prompt);
  }

  public ApplyDamage = () => {
    if (!this.HasSelected()) {
      return;
    }

    const selectedCombatants = this.SelectedCombatants();
    this.applyDamageForCombatants(selectedCombatants);
  };

  public ApplyDamageTargeted = (combatantViewModel: CombatantViewModel) => {
    this.applyDamageForCombatants([combatantViewModel]);
  };

  public ApplyHealing = () => {
    if (!this.HasSelected()) {
      return;
    }
    const selectedCombatants = this.SelectedCombatants();
    const latestRollTotal = this.latestRoll?.Total || 0;
    const prompt = ApplyHealingPrompt(
      selectedCombatants,
      latestRollTotal.toString(),
      this.tracker.EventLog.LogHPChange
    );
    this.tracker.PromptQueue.Add(prompt);
  };

  public UpdateNotes = async () => {
    if (!this.HasOneSelected()) {
      return;
    }

    const combatant = this.SelectedCombatants()[0].Combatant;
    this.tracker.PromptQueue.Add(UpdateNotesPrompt(combatant));
    return false;
  };

  public PromptAcceptSuggestedDamage = (
    suggestedCombatants: CombatantViewModel[],
    suggestedDamage: number,
    suggester: string
  ) => {
    const allowPlayerSuggestions =
      CurrentSettings().PlayerView.AllowPlayerSuggestions;

    if (!allowPlayerSuggestions) {
      return false;
    }

    Metrics.TrackEvent("DamageSuggested", { Amount: suggestedDamage });

    const prompt = AcceptDamagePrompt(
      suggestedCombatants,
      suggestedDamage,
      suggester,
      this.tracker
    );

    this.tracker.PromptQueue.Add(prompt);
    return false;
  };

  public PromptAcceptSuggestedTag = (
    suggestedCombatant: Combatant,
    suggestedTag: TagState
  ) => {
    const prompt = AcceptTagPrompt(
      suggestedCombatant,
      this.tracker.Encounter,
      suggestedTag
    );

    this.tracker.PromptQueue.Add(prompt);
    return false;
  };

  public CheckConcentration = (combatant: Combatant, damageAmount: number) => {
    setTimeout(() => {
      const prompt = ConcentrationPrompt(combatant, damageAmount);
      this.tracker.PromptQueue.Add(prompt);
      Metrics.TrackEvent("ConcentrationCheckTriggered");
    }, 1);
  };

  public AddTemporaryHP = () => {
    if (!this.HasSelected()) {
      return;
    }

    const selectedCombatants = this.SelectedCombatants();
    const combatantNames = selectedCombatants.map(c => c.Name()).join(", ");
    const prompt = ApplyTemporaryHPPrompt(combatantNames, model => {
      if (model.hpAmount) {
        selectedCombatants.forEach(c => c.ApplyTemporaryHP(model.hpAmount));
        this.tracker.EventLog.AddEvent(
          `${model.hpAmount} temporary hit points granted to ${combatantNames}.`
        );
        Metrics.TrackEvent("TemporaryHPAdded", { Amount: model.hpAmount });
      }
      return true;
    });

    this.tracker.PromptQueue.Add(prompt);

    return false;
  };

  public AddTag = (combatantVM?: CombatantViewModel) => {
    let targetCombatants: Combatant[] = [];

    if (combatantVM instanceof CombatantViewModel) {
      targetCombatants = [combatantVM.Combatant];
    } else {
      targetCombatants = this.SelectedCombatants().map(c => c.Combatant);
    }

    if (targetCombatants.length == 0) {
      return;
    }

    const prompt = TagPrompt(
      this.tracker.Encounter,
      targetCombatants,
      this.tracker.EventLog.AddEvent
    );
    this.tracker.PromptQueue.Add(prompt);
    return false;
  };

  public EditInitiative = () => {
    this.SelectedCombatants().forEach(c => c.EditInitiative());
    return false;
  };

  private pendingLinkInitiative = ko.observable<PendingLinkInitiative>(null);

  private linkCombatantInitiatives = (combatants: CombatantViewModel[]) => {
    this.pendingLinkInitiative(null);
    const highestInitiative = combatants
      .map(c => c.Combatant.Initiative())
      .sort((a, b) => b - a)[0];
    const initiativeGroup = probablyUniqueString();

    combatants.forEach(s => {
      s.Combatant.Initiative(highestInitiative);
      s.Combatant.InitiativeGroup(initiativeGroup);
    });
    this.tracker.Encounter.CleanInitiativeGroups();

    this.tracker.Encounter.SortByInitiative();
    Metrics.TrackEvent("InitiativeLinked");
  };

  public LinkInitiative = () => {
    if (!this.HasSelected()) {
      return;
    }

    const selected = this.SelectedCombatants();

    if (selected.length == 1) {
      const prompt = LinkInitiativePrompt(() =>
        this.pendingLinkInitiative(null)
      );
      const promptId = this.tracker.PromptQueue.Add(prompt);
      this.pendingLinkInitiative({ combatant: selected[0], promptId });
      return;
    }

    this.linkCombatantInitiatives(selected);
  };

  public MoveUp = () => {
    if (!this.HasSelected()) {
      return;
    }

    const combatant = this.SelectedCombatants()[0];
    const index = this.tracker.CombatantViewModels().indexOf(combatant);
    if (combatant && index > 0) {
      const newInitiative = this.tracker.Encounter.MoveCombatant(
        combatant.Combatant,
        index - 1
      );
      this.tracker.EventLog.AddEvent(
        `${combatant.Name()} initiative set to ${newInitiative}.`
      );
    }
  };

  public MoveDown = () => {
    if (!this.HasSelected()) {
      return;
    }

    const combatant = this.SelectedCombatants()[0];
    const index = this.tracker.CombatantViewModels().indexOf(combatant);
    if (combatant && index < this.tracker.CombatantViewModels().length - 1) {
      const newInitiative = this.tracker.Encounter.MoveCombatant(
        combatant.Combatant,
        index + 1
      );
      this.tracker.EventLog.AddEvent(
        `${combatant.Name()} initiative set to ${newInitiative}.`
      );
    }
  };

  public SetAlias = () => {
    if (!this.HasSelected()) {
      return;
    }

    this.SelectedCombatants().forEach(c => c.SetAlias());
    return false;
  };

  public ToggleSpentReaction = () => {
    if (!this.HasSelected()) {
      return;
    }

    this.SelectedCombatants().forEach(c => c.ToggleSpentReaction());
  };

  public ToggleHidden = () => {
    if (!this.HasSelected()) {
      return;
    }

    this.SelectedCombatants().forEach(c => c.ToggleHidden());
  };

  public ToggleRevealedAC = () => {
    if (!this.HasSelected()) {
      return;
    }

    this.SelectedCombatants().forEach(c => c.ToggleRevealedAC());
  };

  public EditOwnStatBlock = () => {
    if (!this.HasOneSelected()) {
      return;
    }

    const selectedCombatant = this.SelectedCombatants()[0].Combatant;
    if (selectedCombatant.PersistentCharacterId) {
      this.tracker.EditPersistentCharacterStatBlock(
        selectedCombatant.PersistentCharacterId
      );
    } else {
      this.tracker.EditStatBlock({
        editorTarget: "combatant",
        statBlock: selectedCombatant.StatBlock(),
        onSave: newStatBlock => {
          selectedCombatant.StatBlock(newStatBlock);
        },
        onDelete: () => this.Remove()
      });
    }
  };

  public QuickEditOwnStatBlock = () => {
    if (!this.HasOneSelected()) {
      return;
    }

    const selectedCombatant = this.SelectedCombatants()[0].Combatant;

    const prompt = QuickEditStatBlockPrompt(
      selectedCombatant,
      updatedStatBlock => {
        if (selectedCombatant.PersistentCharacterId) {
          this.tracker.LibrariesCommander.UpdatePersistentCharacterStatBlockInLibraryAndEncounter(
            selectedCombatant.PersistentCharacterId,
            updatedStatBlock
          );
        } else {
          selectedCombatant.StatBlock(updatedStatBlock);
        }
      }
    );
    this.tracker.PromptQueue.Add(prompt);
  };

  public RollDice = (diceExpression: string) => {
    const diceRoll = Dice.RollDiceExpression(diceExpression);
    this.latestRoll = diceRoll;
    const prompt = ShowDiceRollPrompt(diceExpression, diceRoll);

    Metrics.TrackEvent("DiceRolled", {
      Expression: diceExpression,
      Result: diceRoll.FormattedString
    });
    this.tracker.PromptQueue.Add(prompt);
  };

  public Duplicate = () => {
    if (!this.HasSelected()) {
      return;
    }

    const selectedCombatants = this.SelectedCombatants();
    selectedCombatants.forEach(c => {
      if (c.Combatant.PersistentCharacterId) {
        return;
      }

      this.tracker.Encounter.AddCombatantFromState({
        ...c.Combatant.GetState(),
        Id: probablyUniqueString()
      });
    });

    this.tracker.EventLog.AddEvent(
      `${selectedCombatants.map(c => c.Name()).join(", ")} duplicated.`
    );
  };
}


================================================
FILE: client/Commands/Command.test.ts
================================================
import { getDefaultSettings } from "../../common/Settings";
import { LegacySynchronousLocalStore } from "../Utility/LegacySynchronousLocalStore";
import { Command } from "./Command";

const MakeCommand = () => ({
  id: "some-command-id",
  description: "Some Command",
  actionBinding: jest.fn(),
  fontAwesomeIcon: "square"
});

describe("Command", () => {
  test("Should look up default keybinding by id", () => {
    const command = new Command({
      ...MakeCommand(),
      id: "start-encounter"
    });
    expect(command.KeyBinding).toEqual("alt+r");
  });

  test("Should load a saved keybinding", () => {
    const settings = getDefaultSettings();
    settings.Commands = [
      {
        Name: "some-command-id",
        KeyBinding: "saved-keybinding",
        ShowOnActionBar: true,
        ShowInCombatantRow: false
      }
    ];
    LegacySynchronousLocalStore.Save(
      LegacySynchronousLocalStore.User,
      "Settings",
      settings
    );

    const command = new Command(MakeCommand());
    expect(command.KeyBinding).toEqual("saved-keybinding");
  });

  test("Should load a keybinding with a legacy Add Note id", () => {
    const settings = getDefaultSettings();
    settings.Commands = [
      {
        Name: "Add Note",
        KeyBinding: "legacy-keybinding",
        ShowOnActionBar: true,
        ShowInCombatantRow: false
      }
    ];
    LegacySynchronousLocalStore.Save(
      LegacySynchronousLocalStore.User,
      "Settings",
      settings
    );

    const command = new Command({
      ...MakeCommand(),
      id: "add-tag"
    });
    expect(command.KeyBinding).toEqual("legacy-keybinding");
  });

  test("Should load a keybinding from the old Store", () => {
    LegacySynchronousLocalStore.Save(
      LegacySynchronousLocalStore.KeyBindings,
      "Add Note",
      "legacy-keybinding"
    );
    const command = new Command({
      ...MakeCommand(),
      id: "add-tag"
    });
    expect(command.KeyBinding).toEqual("legacy-keybinding");
  });
});


================================================
FILE: client/Commands/Command.ts
================================================
import * as ko from "knockout";

import * as _ from "lodash";

import { Settings } from "../../common/Settings";
import { LegacySynchronousLocalStore } from "../Utility/LegacySynchronousLocalStore";
import { GetLegacyKeyBinding } from "./GetLegacyKeyBinding";
import { DefaultKeybindings } from "./DefaultKeybindings";

export class Command {
  public ShowOnActionBar: KnockoutObservable<boolean>;
  public ShowInCombatantRow: KnockoutObservable<boolean>;
  public ToolTip: ko.PureComputed<string>;
  public KeyBinding: string;
  public Id: string;
  public Description: string;
  public ActionBinding: () => any;
  public FontAwesomeIcon: ko.Computed<string>;
  public LockOnActionBar?: boolean;

  constructor(props: {
    id: string;
    description: string;
    actionBinding: () => any;
    fontAwesomeIcon: string | (() => string);
    defaultShowOnActionBar?: boolean;
    defaultShowInCombatantRow?: boolean;
    lockOnActionBar?: boolean;
  }) {
    this.Id = props.id;
    this.Description = props.description;
    this.ActionBinding = props.actionBinding;

    if (typeof props.fontAwesomeIcon === "string") {
      this.FontAwesomeIcon = ko.computed(() => props.fontAwesomeIcon as string);
    } else {
      this.FontAwesomeIcon = ko.computed(props.fontAwesomeIcon);
    }

    this.LockOnActionBar = props.lockOnActionBar || false;

    this.ShowOnActionBar = ko.observable(props.defaultShowOnActionBar ?? true);
    this.ShowInCombatantRow = ko.observable(
      props.defaultShowInCombatantRow ?? false
    );

    if (this.LockOnActionBar) {
      this.ShowOnActionBar.subscribe(_ => {
        this.ShowOnActionBar(true);
      });
    }

    this.ToolTip = ko.pureComputed(
      () => `${this.Description} [${this.KeyBinding}]`
    );

    const settings = LegacySynchronousLocalStore.Load<Settings>(
      LegacySynchronousLocalStore.User,
      "Settings"
    );
    const commandSetting =
      settings && _.find(settings.Commands, c => c.Name == this.Id);

    if (commandSetting == undefined) {
      this.KeyBinding =
        GetLegacyKeyBinding(this.Id) || DefaultKeybindings[this.Id] || "";
      const showOnActionBarSetting = LegacySynchronousLocalStore.Load<boolean>(
        LegacySynchronousLocalStore.ActionBar,
        this.Description
      );
      if (showOnActionBarSetting != null) {
        this.ShowOnActionBar(showOnActionBarSetting);
      }
    } else {
      this.KeyBinding = commandSetting.KeyBinding;
      this.ShowOnActionBar(commandSetting.ShowOnActionBar);
    }
  }
}


================================================
FILE: client/Commands/CommandButton.tsx
================================================
import { Command } from "./Command";
import { Button } from "../Components/Button";
import * as React from "react";

import { useSubscription } from "../Combatant/linkComponentToObservables";

export function CommandButton(props: { command: Command; showLabel: boolean }) {
  const c = props.command;
  const buttonIsOnActionBar = useSubscription(c.ShowOnActionBar);
  const fontAwesomeIcon = useSubscription(c.FontAwesomeIcon);

  if (!buttonIsOnActionBar) {
    return null;
  }

  const buttonText = props.showLabel && c.Description;
  return (
    <Button
      additionalClassNames={"c-button--" + c.Id}
      key={c.Description}
      tooltip={commandButtonTooltip(c)}
      tooltipProps={{
        placement: "right",
        delay: 1000
      }}
      onClick={c.ActionBinding}
      fontAwesomeIcon={fontAwesomeIcon}
      text={buttonText}
    />
  );
}

function commandButtonTooltip(c: Command) {
  if (c.KeyBinding) {
    return `${c.Description} [${c.KeyBinding}]`;
  } else {
    return c.Description;
  }
}


================================================
FILE: client/Commands/DefaultKeybindings.ts
================================================
export const DefaultKeybindings: { [commandId: string]: string } = {
  "toggle-menu": "alt+m",
  "start-encounter": "alt+r",
  "reroll-initiative": "alt+shift+i",
  "end-encounter": "shift+alt+r",
  "clear-encounter": "alt+shift+del",
  "clean-encounter": "alt+del",
  "open-library": "alt+a",
  "open-library-manager": "alt+shift+a",
  "roll-dice": "d",
  "quick-add": "alt+q",
  "restore-all-player-character-hp": "alt+shift+t",
  "player-window": "alt+w",
  "toggle-full-screen": "f11",
  "next-turn": "n",
  "previous-turn": "alt+n",
  "save-encounter": "alt+s",
  settings: "?",
  "apply-damage": "t",
  "apply-healing": "l",
  "apply-temporary-hp": "alt+t",
  "add-tag": "g",
  "update-notes": "y",
  remove: "del",
  "set-alias": "f2",
  "toggle-reaction": "r",
  "toggle-hidden": "h",
  "toggle-reveal-ac": "alt+h",
  "quick-edit-statblock": "e",
  "edit-statblock": "shift+e",
  "set-initiative": "alt+i",
  "link-initiative": "alt+l",
  "move-down": "alt+j",
  "move-up": "alt+k",
  "select-next": "j",
  "select-previous": "k",
  "duplicate-combatant": "alt+d"
};


================================================
FILE: client/Commands/EncounterCommander.test.ts
================================================
import { PersistentCharacter } from "../../common/PersistentCharacter";
import { StatBlock } from "../../common/StatBlock";
import { Encounter } from "../Encounter/Encounter";
import { InitializeTestSettings } from "../test/InitializeTestSettings";
import { TrackerViewModel } from "../TrackerViewModel";
import { buildEncounter } from "../test/buildEncounter";
import { EncounterCommander } from "./EncounterCommander";
import { act, renderHook } from "@testing-library/react-hooks";
import { useLibraries } from "../Library/Libraries";
import { CurrentSettings } from "../Settings/Settings";
import { MockAccountClient } from "../MockAccountClient";

describe("EncounterCommander", () => {
  let encounter: Encounter;
  let encounterCommander: EncounterCommander;
  let trackerViewModel: TrackerViewModel;
  beforeEach(() => {
    window.confirm = () => true;
    InitializeTestSettings({
      PreloadedContent: {
        BasicRules: false,
        Open5eContent: false
      }
    });

    const mockIo: any = {
      on: jest.fn(),
      emit: jest.fn()
    };

    const librariesHook = renderHook(() =>
      useLibraries(CurrentSettings(), MockAccountClient(), () => {})
    );
    const libraries = librariesHook.result.current;
    trackerViewModel = new TrackerViewModel(mockIo);
    trackerViewModel.SetLibraries(libraries);
    encounter = trackerViewModel.Encounter;
    encounterCommander = trackerViewModel.EncounterCommander;
  });

  afterEach(() => {
    encounter.ClearEncounter();
  });

  test("Cannot start an empty encounter.", () => {
    encounterCommander.StartEncounter();
    expect(encounter.EncounterFlow.State()).toBe("inactive");
    expect(encounter.Combatants().length).toBe(0);
    expect(!encounter.EncounterFlow.ActiveCombatant());
  });

  test("Click Next Turn with no combatants.", () => {
    encounter.EncounterFlow.NextTurn = jest.fn(
      encounter.EncounterFlow.NextTurn
    );
    expect(!encounter.EncounterFlow.ActiveCombatant());
    encounterCommander.NextTurn();
    expect(encounter.EncounterFlow.CombatTimer.ElapsedRounds() == 1);
    expect(encounter.EncounterFlow.NextTurn).not.toBeCalled();
  });

  test("Calling Next Turn should start an inactive encounter.", () => {
    const startEncounter = (encounterCommander.StartEncounter = jest.fn());

    encounter.AddCombatantFromStatBlock(StatBlock.Default());
    expect(!encounter.EncounterFlow.ActiveCombatant());
    encounterCommander.NextTurn();

    expect(startEncounter).toBeCalled();
  });

  test.skip("CleanEncounter", async done => {
    const persistentCharacter = PersistentCharacter.Initialize({
      ...StatBlock.Default(),
      Player: "player"
    });
    encounter.AddCombatantFromStatBlock(StatBlock.Default());
    await encounter.AddCombatantFromPersistentCharacter(
      persistentCharacter,
      () => {},
      false
    );

    expect(encounter.Combatants().length).toBe(2);
    encounterCommander.CleanEncounter();
    expect(encounter.Combatants().length).toBe(1);
    return done();
  });

  test.skip("ClearEncounter", async done => {
    const persistentCharacter = PersistentCharacter.Initialize({
      ...StatBlock.Default(),
      Player: "player"
    });
    encounter.AddCombatantFromStatBlock(StatBlock.Default());
    await encounter.AddCombatantFromPersistentCharacter(
      persistentCharacter,
      () => {},
      false
    );

    expect(encounter.Combatants().length).toBe(2);
    encounterCommander.ClearEncounter();
    expect(encounter.Combatants().length).toBe(0);
    return done();
  });

  test.skip("Restore Player Character HP", async done => {
    const persistentCharacter = PersistentCharacter.Initialize({
      ...StatBlock.Default(),
      Player: "player"
    });

    const npc = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    const pc = await encounter.AddCombatantFromPersistentCharacter(
      persistentCharacter,
      () => {},
      false
    );

    expect(npc.CurrentHP()).toBe(1);
    expect(pc.CurrentHP()).toBe(1);

    npc.ApplyDamage(1);
    pc.ApplyDamage(1);

    expect(npc.CurrentHP()).toBe(0);
    expect(pc.CurrentHP()).toBe(0);

    encounterCommander.RestoreAllPlayerCharacterHP();

    expect(npc.CurrentHP()).toBe(0);
    expect(pc.CurrentHP()).toBe(1);

    return done();
  });

  function buildSavedEncounterWithPersistentCharacter() {
    const npcStatBlock = { ...StatBlock.Default(), Name: "Goblin" };
    const persistentCharacter = PersistentCharacter.Initialize({
      ...StatBlock.Default(),
      Name: "Encounter Gregorr"
    });
    const oldEncounter = buildEncounter();
    oldEncounter.AddCombatantFromStatBlock(npcStatBlock);
    oldEncounter.AddCombatantFromPersistentCharacter(
      persistentCharacter,
      () => {},
      false
    );
    const savedEncounter = oldEncounter.ObservableEncounterState();
    return savedEncounter;
  }

  test("LoadEncounter loads non-persistent combatants", () => {
    const savedEncounter = buildSavedEncounterWithPersistentCharacter();
    act(() => {
      encounterCommander.LoadSavedEncounter(savedEncounter);
    });
    expect(encounter.Combatants()[0].DisplayName()).toEqual("Goblin");
  });

  test.skip("LoadEncounter loads the current version of persistent combatants", async () => {
    const savedEncounter = buildSavedEncounterWithPersistentCharacter();

    const persistentCharacter = PersistentCharacter.Initialize({
      ...StatBlock.Default(),
      Name: "Library Gregorr"
    });
    persistentCharacter.Id = savedEncounter.Combatants[1].PersistentCharacterId;

    await trackerViewModel.Libraries.PersistentCharacters.SaveNewListing(
      persistentCharacter
    );
    await encounterCommander.LoadSavedEncounter(savedEncounter);

    expect(encounter.Combatants()[1].DisplayName()).toEqual("Library Gregorr");
  });

  describe.skip("Index Labelling and Saved Encounters", () => {
    function buildEncounterState() {
      const statBlock = { ...StatBlock.Default(), Name: "Goblin" };
      const oldEncounter = buildEncounter();
      for (const initiative of [8, 10]) {
        const combatant = oldEncounter.AddCombatantFromStatBlock(statBlock);
        combatant.Initiative(initiative);
      }
      oldEncounter.EncounterFlow.StartEncounter();
      const savedEncounter = oldEncounter.ObservableEncounterState();
      return savedEncounter;
    }

    test("When a combatant is added from a saved encounter, it retains its saved index label", () => {
      const savedEncounter = buildEncounterState();

      encounterCommander.LoadSavedEncounter(savedEncounter);

      const combatantDisplayNames = encounter
        .Combatants()
        .map(c => [c.DisplayName(), c.Initiative()]);

      expect(combatantDisplayNames).toEqual([
        ["Goblin 1", 8],
        ["Goblin 2", 10]
      ]);
    });

    test("When a saved encounter is added twice, it relabels existing creatures", () => {
      const savedEncounter = buildEncounterState();
      encounterCommander.LoadSavedEncounter(savedEncounter);
      encounterCommander.LoadSavedEncounter(savedEncounter);

      const combatantDisplayNames = encounter
        .Combatants()
        .map(c => c.DisplayName());

      expect(combatantDisplayNames).toEqual([
        "Goblin 1",
        "Goblin 2",
        "Goblin 3",
        "Goblin 4"
      ]);
    });

    test("When a saved encounter is repeatedly added in waves, index labeling is consistent", () => {
      const savedEncounter = buildEncounterState();
      encounterCommander.LoadSavedEncounter(savedEncounter);

      encounter.RemoveCombatant(encounter.Combatants()[1]);

      encounterCommander.LoadSavedEncounter(savedEncounter);

      const combatantDisplayNames = encounter
        .Combatants()
        .map(c => c.DisplayName());

      expect(combatantDisplayNames).toEqual([
        "Goblin 1",
        "Goblin 3",
        "Goblin 4"
      ]);
    });
  });
});


================================================
FILE: client/Commands/EncounterCommander.ts
================================================
import * as _ from "lodash";

import { CombatStats } from "../../common/CombatStats";
import { PostCombatStatsOption } from "../../common/Settings";
import { UpdateLegacySavedEncounter } from "../Encounter/UpdateLegacySavedEncounter";
import { env } from "../Environment";
import { CurrentSettings } from "../Settings/Settings";
import { TrackerViewModel } from "../TrackerViewModel";
import { NotifyTutorialOfAction } from "../Tutorial/NotifyTutorialOfAction";
import { Metrics } from "../Utility/Metrics";
import { CombatStatsPrompt } from "../Prompts/CombatStatsPrompt";
import { InitiativePrompt } from "../Prompts/InitiativePrompt";
import { PlayerViewPrompt } from "../Prompts/PlayerViewPrompt";
import { QuickAddPrompt } from "../Prompts/QuickAddPrompt";
import { RollDicePrompt } from "../Prompts/RollDicePrompt";
import { ToggleFullscreen } from "./ToggleFullscreen";
import { PersistentCharacter } from "../../common/PersistentCharacter";

export class EncounterCommander {
  constructor(private tracker: TrackerViewModel) {}

  public QuickAddStatBlock = (): void => {
    const prompt = QuickAddPrompt(
      this.tracker.Encounter.AddCombatantFromStatBlock
    );
    this.tracker.PromptQueue.Add(prompt);
  };

  public ShowLibraries = (): void => this.tracker.LibrariesVisible(true);
  public HideLibraries = (): void => this.tracker.LibrariesVisible(false);

  public ToggleLibraryManager = (): void => this.tracker.ToggleLibraryManager();

  public LaunchPlayerView = (): void => {
    const prompt = PlayerViewPrompt(
      env.EncounterId,
      this.tracker.Encounter.TemporaryBackgroundImageUrl() ?? "",
      backgroundImageUrl =>
        this.tracker.Encounter.TemporaryBackgroundImageUrl(backgroundImageUrl),
      this.requestCustomEncounterIdAndUpdateEncounter
    );
    this.tracker.PromptQueue.Add(prompt);

    Metrics.TrackEvent("PlayerViewLaunched", {
      Id: env.EncounterId
    });
  };

  private requestCustomEncounterIdAndUpdateEncounter = async (
    requestedId: string
  ) => {
    const didGrantId =
      await this.tracker.PlayerViewClient.RequestCustomEncounterId(requestedId);

    if (didGrantId) {
      env.EncounterId = requestedId;
      const settings = CurrentSettings();
      settings.PlayerView.CustomEncounterId = requestedId;
      this.tracker.SaveUpdatedSettings(settings);
      this.tracker.PlayerViewClient.UpdateEncounter(
        requestedId,
        this.tracker.Encounter.GetPlayerView()
      );
    }

    return didGrantId;
  };

  public ToggleFullScreen = (): boolean => {
    ToggleFullscreen();
    Metrics.TrackEvent("FullscreenToggled");
    return false;
  };

  public ShowSettings = (): void => {
    NotifyTutorialOfAction("ShowSettings");
    this.tracker.SettingsVisible(true);
    Metrics.TrackEvent("SettingsOpened");
  };

  public ToggleToolbarWidth = (): void => {
    this.tracker.ToolbarWide(!this.tracker.ToolbarWide());
  };

  private ShowInitiativePrompt = (): Promise<void> => {
    return new Promise<void>(done => {
      this.tracker.PromptQueue.Add(
        InitiativePrompt(this.tracker.Encounter.Combatants(), () => {
          this.tracker.Encounter.EncounterFlow.StartEncounter();
          done();
        })
      );
    });
  };

  public StartEncounter = (): boolean => {
    if (this.tracker.Encounter.Combatants().length == 0) {
      this.tracker.EventLog.AddEvent("Cannot start empty encounter.");
      return;
    }

    this.HideLibraries();

    if (this.tracker.Encounter.EncounterFlow.State() == "active") {
      return;
    }

    this.ShowInitiativePrompt();

    NotifyTutorialOfAction("ShowInitiativeDialog");

    this.tracker.EventLog.AddEvent("Encounter started.");
    Metrics.TrackEvent("EncounterStarted", {
      CombatantCount: this.tracker.Encounter.Combatants().length
    });

    return false;
  };

  public EndEncounter = (): boolean => {
    if (this.tracker.Encounter.EncounterFlow.State() == "inactive") {
      return;
    }

    this.tracker.Encounter.EncounterFlow.EndEncounter();
    this.tracker.EventLog.AddEvent("Encounter ended.");
    Metrics.TrackEvent("EncounterEnded", {
      Combatants: this.tracker.Encounter.Combatants().length
    });

    const displayPostCombatStats =
      CurrentSettings().TrackerView.PostCombatStats;

    if (displayPostCombatStats != PostCombatStatsOption.None) {
      const combatTimer = this.tracker.Encounter.EncounterFlow.CombatTimer;

      const combatStats: CombatStats = {
        elapsedRounds: combatTimer.ElapsedRounds(),
        elapsedSeconds: combatTimer.ElapsedSeconds(),
        combatants: this.tracker.Encounter.Combatants()
          .filter(c => c.IsPlayerCharacter())
          .map(c => ({
            displayName: c.DisplayName(),
            elapsedRounds: c.CombatTimer.ElapsedRounds(),
            elapsedSeconds: c.CombatTimer.ElapsedSeconds()
          }))
      };

      if (
        displayPostCombatStats == PostCombatStatsOption.EncounterViewOnly ||
        displayPostCombatStats == PostCombatStatsOption.Both
      ) {
        const combatStatsPrompt = CombatStatsPrompt(combatStats);
        this.tracker.PromptQueue.Add(combatStatsPrompt);
      }

      if (
        displayPostCombatStats == PostCombatStatsOption.PlayerViewOnly ||
        displayPostCombatStats == PostCombatStatsOption.Both
      ) {
        this.tracker.Encounter.DisplayPlayerViewCombatStats(combatStats);
      }
    }

    this.tracker.Encounter.Combatants().forEach(c => c.CombatTimer.Stop());
    this.tracker.Encounter.EncounterFlow.CombatTimer.Stop();

    return false;
  };

  public RerollInitiative = async (): Promise<void> => {
    await this.ShowInitiativePrompt();
    Metrics.TrackEvent("InitiativeRerolled");
  };

  public ClearEncounter = (): boolean => {
    if (confirm("Remove all combatants and end encounter?")) {
      this.tracker.Encounter.ClearEncounter();
      this.tracker.CombatantCommander.Deselect();
      this.tracker.EventLog.AddEvent("All combatants removed from encounter.");
      Metrics.TrackEvent("EncounterCleared");
    }

    return false;
  };

  public CleanEncounter = (): boolean => {
    if (confirm("Remove NPCs and end encounter?")) {
      const npcViewModels = this.tracker
        .CombatantViewModels()
        .filter(c => !c.Combatant.IsPlayerCharacter());
      this.tracker.CombatantCommander.Deselect();
      this.tracker.Encounter.EncounterFlow.EndEncounter();
      npcViewModels.forEach(vm =>
        this.tracker.Encounter.RemoveCombatant(vm.Combatant)
      );
      this.tracker.Encounter.CombatantCountsByName({});
      Metrics.TrackEvent("EncounterCleaned");
    }

    return false;
  };

  public RestoreAllPlayerCharacterHP = (): void => {
    const playerCharacters = this.tracker.Encounter.Combatants().filter(c =>
      c.IsPlayerCharacter()
    );
    playerCharacters.forEach(pc => pc.CurrentHP(pc.MaxHP()));
    this.tracker.EventLog.AddEvent("All player character HP was restored.");
    Metrics.TrackEvent("AllPlayerCharacterHPRestored");
  };

  public LoadSavedEncounter = async (
    legacySavedEncounter: Record<string, any>
  ): Promise<void> => {
    const savedEncounter = UpdateLegacySavedEncounter(legacySavedEncounter);

    const nonCharacterCombatants = savedEncounter.Combatants.filter(
      c => !c.PersistentCharacterId
    );

    const nonCharacterCombatantsInLabelOrder = _.sortBy(
      nonCharacterCombatants,
      c => c.IndexLabel
    );

    nonCharacterCombatantsInLabelOrder.forEach(
      this.tracker.Encounter.AddCombatantFromState
    );

    const persistentCharacters = savedEncounter.Combatants.filter(
      c => c.PersistentCharacterId
    );

    const persistentCharactersPromise = persistentCharacters.map(
      pc =>
        new Promise<void>(async resolve => {
          const persistentCharacterListing =
            await this.tracker.Libraries.PersistentCharacters.GetOrCreateListingById(
              pc.PersistentCharacterId
            );
          const persistentCharacter =
            await persistentCharacterListing.GetWithTemplate(
              PersistentCharacter.Default()
            );
          this.tracker.Encounter.AddCombatantFromPersistentCharacter(
            persistentCharacter,
            this.tracker.LibrariesCommander.UpdatePersistentCharacter,
            pc.Hidden,
            pc.Initiative
          );
          resolve();
        })
    );

    this.tracker.Encounter.TemporaryBackgroundImageUrl(
      savedEncounter.BackgroundImageUrl
    );

    Metrics.TrackEvent("EncounterLoaded", {
      Name: savedEncounter.Name,
      Combatants: savedEncounter.Combatants.map(c => c.StatBlock.Name)
    });

    await Promise.all(persistentCharactersPromise);

    this.tracker.Encounter.SortByInitiative(true);
  };

  public NextTurn = async (): Promise<boolean> => {
    if (this.tracker.Encounter.EncounterFlow.State() != "active") {
      this.StartEncounter();
      return;
    }

    if (this.tracker.Encounter.Combatants().length == 0) {
      return;
    }

    if (!this.tracker.Encounter.EncounterFlow.ActiveCombatant()) {
      this.tracker.Encounter.EncounterFlow.ActiveCombatant(
        this.tracker.Encounter.Combatants()[0]
      );
      return;
    }

    const turnEndCombatant =
      this.tracker.Encounter.EncounterFlow.ActiveCombatant();
    if (turnEndCombatant) {
      Metrics.TrackEvent("TurnCompleted", {
        Name: turnEndCombatant.DisplayName()
      });
    }

    await this.tracker.Encounter.EncounterFlow.NextTurn(this.RerollInitiative);

    const turnStartCombatant =
      this.tracker.Encounter.EncounterFlow.ActiveCombatant();
    this.tracker.EventLog.AddEvent(
      `Start of turn for ${turnStartCombatant.DisplayName()}.`
    );

    return false;
  };

  public PreviousTurn = (): boolean => {
    if (!this.tracker.Encounter.EncounterFlow.ActiveCombatant()) {
      return;
    }

    this.tracker.Encounter.EncounterFlow.PreviousTurn();
    const currentCombatant =
      this.tracker.Encounter.EncounterFlow.ActiveCombatant();
    this.tracker.EventLog.AddEvent(
      `Initiative rewound to ${currentCombatant.DisplayName()}.`
    );

    return false;
  };

  public PromptRollDice = (): void => {
    this.tracker.PromptQueue.Add(
      RollDicePrompt(this.tracker.CombatantCommander.RollDice)
    );
  };
}


================================================
FILE: client/Commands/GetLegacyKeyBinding.ts
================================================
import * as _ from "lodash";

import { Settings } from "../../common/Settings";
import { LegacySynchronousLocalStore } from "../Utility/LegacySynchronousLocalStore";

const LegacyCommandSettingsKeys = {
  "toggle-menu": "Toggle Menu",
  "start-encounter": "Start Encounter",
  "reroll-initiative": "Reroll Initiative",
  "end-encounter": "End Encounter",
  "clean-encounter": "Clear Encounter",
  "open-library": "Open Library",
  "quick-add": "Quick Add Combatant",
  "player-window": "Show Player Window",
  "next-turn": "Next Turn",
  "previous-turn": "Previous Turn",
  "save-encounter": "Save Encounter",
  settings: "Settings",
  "apply-damage": "Damage/Heal",
  "apply-temporary-hp": "Apply Temporary HP",
  "add-tag": "Add Note",
  remove: "Remove from Encounter",
  "set-alias": "Rename",
  "edit-statblock": "Edit Statblock",
  "set-initiative": "Edit Initiative",
  "link-initiative": "Link Initiative",
  "move-down": "Move Down",
  "move-up": "Move Up",
  "select-next": "Select Next",
  "select-previous": "Select Previous"
};

export function GetLegacyKeyBinding(id: string) {
  const settings = LegacySynchronousLocalStore.Load<Settings>(
    LegacySynchronousLocalStore.User,
    "Settings"
  );
  const legacyId = LegacyCommandSettingsKeys[id];
  const commandSetting = _.find(
    settings?.Commands || [],
    c => c.Name == legacyId
  );
  if (commandSetting?.KeyBinding) {
    return commandSetting.KeyBinding;
  }

  const legacyKeybinding = LegacySynchronousLocalStore.Load<string>(
    LegacySynchronousLocalStore.KeyBindings,
    legacyId
  );
  if (legacyKeybinding) {
    return legacyKeybinding;
  }

  return null;
}


================================================
FILE: client/Commands/LibrariesCommander.ts
================================================
import * as ko from "knockout";
import * as _ from "lodash";

import { CombatantState } from "../../common/CombatantState";
import { EncounterState } from "../../common/EncounterState";
import { PersistentCharacter } from "../../common/PersistentCharacter";
import { Spell } from "../../common/Spell";
import { StatBlock } from "../../common/StatBlock";
import {
  concatenatedStringRegex,
  probablyUniqueString
} from "../../common/Toolbox";
import { VariantMaximumHP } from "../Combatant/GetOrRollMaximumHP";
import { Libraries, LibraryType } from "../Library/Libraries";
import { Listing } from "../Library/Listing";
import { TrackerViewModel } from "../TrackerViewModel";
import { Metrics } from "../Utility/Metrics";
import { EncounterCommander } from "./EncounterCommander";
import { MoveEncounterPrompt } from "../Prompts/MoveEncounterPrompt";
import { SaveEncounterPrompt } from "../Prompts/SaveEncounterPrompt";
import { SpellPrompt } from "../Prompts/SpellPrompt";
import { ConditionReferencePrompt } from "../Prompts/ConditionReferencePrompt";
import { SavedEncounter } from "../../common/SavedEncounter";
import { now } from "moment";
import { Library } from "../Library/useLibrary";
import { CurrentSettings } from "../Settings/Settings";

export class LibrariesCommander {
  private libraries: Libraries;

  constructor(
    private tracker: TrackerViewModel,
    private encounterCommander: EncounterCommander
  ) {}

  public SetLibraries = (libraries: Libraries): void => {
    // I don't like this pattern, but it's my first stab at a partial
    // conversion to allow an observable-backed class to also depend
    // on a React hook. This will probably catch fire at some point.
    // It's also probably impossible to test.
    this.libraries = libraries;
  };

  public ShowLibraries = (): void => this.tracker.LibrariesVisible(true);
  public HideLibraries = (): void => this.tracker.LibrariesVisible(false);
  public OpenLibraryManagerPane = (startPane: LibraryType): any =>
    this.tracker.LibraryManagerPane(startPane);

  public AddStatBlockFromListing = (
    listing: Listing<StatBlock>,
    hideOnAdd: boolean,
    variantMaximumHP: VariantMaximumHP
  ): boolean => {
    listing.GetAsyncWithUpdatedId(unsafeStatBlock => {
      const statBlock = { ...StatBlock.Default(), ...unsafeStatBlock };
      this.tracker.Encounter.AddCombatantFromStatBlock(
        statBlock,
        hideOnAdd,
        variantMaximumHP
      );
      Metrics.TrackEvent("CombatantAdded", { Name: statBlock.Name });
      this.tracker.EventLog.AddEvent(`${statBlock.Name} added to combat.`);
      const settings = CurrentSettings();
      settings.RecentItemIds = [
        statBlock.Id,
        ...settings.RecentItemIds.filter(id => id !== statBlock.Id)
      ].slice(0, 100);
      this.tracker.SaveUpdatedSettings(settings);
    });
    return true;
  };

  public CanAddPersistentCharacter = (
    listing: Listing<PersistentCharacter>
  ): boolean => {
    return this.tracker.Encounter.CanAddCombatant(listing.Meta().Id);
  };

  public AddPersistentCharacterFromListing = async (
    listing: Listing<PersistentCharacter>,
    hideOnAdd: boolean
  ): Promise<void> => {
    const character = await listing.GetWithTemplate(
      PersistentCharacter.Default()
    );
    this.tracker.Encounter.AddCombatantFromPersistentCharacter(
      character,
      this.UpdatePersistentCharacter,
      hideOnAdd
    );
    Metrics.TrackEvent("PersistentCharacterAdded", { Name: character.Name });
    this.tracker.EventLog.AddEvent(
      `Character ${character.Name} added to combat.`
    );
  };

  public UpdatePersistentCharacter = async (
    persistentCharacterId: string,
    updates: Partial<PersistentCharacter>
  ): Promise<Listing<PersistentCharacter>> => {
    if (updates.StatBlock) {
      updates.Name = updates.StatBlock.Name;
      updates.Path = updates.StatBlock.Path;
      updates.Version = updates.StatBlock.Version;
    }

    const currentCharacterListing =
      await this.libraries.PersistentCharacters.GetOrCreateListingById(
        persistentCharacterId
      );

    const currentCharacter = await currentCharacterListing.GetWithTemplate(
      PersistentCharacter.Default()
    );

    const updatedCharacter = {
      ...currentCharacter,
      ...updates,
      LastUpdateMs: now()
    };

    return await this.libraries.PersistentCharacters.SaveEditedListing(
      currentCharacterListing,
      updatedCharacter
    );
  };

  public CreateAndEditStatBlock = (library: Library<StatBlock>): void => {
    const statBlock = StatBlock.Default();
    const newId = probablyUniqueString();

    statBlock.Name = "New Creature";
    statBlock.Id = newId;

    this.tracker.EditStatBlock({
      editorTarget: "library",
      statBlock,
      onSave: library.SaveNewListing,
      currentListings: library.GetAllListings()
    });
  };

  public EditStatBlock = (
    listing: Listing<StatBlock>,
    library: Library<StatBlock>
  ): void => {
    if (this.tracker.TutorialVisible()) {
      return;
    }

    listing.GetAsyncWithUpdatedId(statBlock => {
      if (listing.Origin === "server") {
        const statBlockWithNewId = {
          ...StatBlock.Default(),
          ...statBlock,
          Id: probablyUniqueString()
        };
        this.tracker.EditStatBlock({
          editorTarget: "library",
          statBlock: statBlockWithNewId,
          onSave: library.SaveNewListing,
          onSaveAsCharacter: this.saveStatblockAsPersistentCharacter,
          currentListings: library.GetAllListings()
        });
      } else {
        this.tracker.EditStatBlock({
          editorTarget: "library",
          statBlock: { ...StatBlock.Default(), ...statBlock },
          onSave: s => library.SaveEditedListing(listing, s),
          currentListings: library.GetAllListings(),
          onDelete: this.deleteSavedStatBlock(listing.Meta().Id),
          onSaveAsCopy: library.SaveNewListing,
          onSaveAsCharacter: this.saveStatblockAsPersistentCharacter
        });
      }
    });
  };

  public CreatePersistentCharacter = async (): Promise<
    Listing<PersistentCharacter>
  > => {
    const statBlock = StatBlock.Default();
    const newId = probablyUniqueString();

    statBlock.Name = "New Character";
    statBlock.Player = "player";
    statBlock.Id = newId;

    const persistentCharacter = PersistentCharacter.Initialize(statBlock);
    return await this.libraries.PersistentCharacters.SaveNewListing(
      persistentCharacter
    );
  };

  public EditPersistentCharacterStatBlock(
    persistentCharacterId: string
  ): Promise<void> {
    if (this.tracker.TutorialVisible()) {
      return;
    }
    this.tracker.EditPersistentCharacterStatBlock(persistentCharacterId);
  }

  public UpdatePersistentCharacterStatBlockInLibraryAndEncounter = (
    persistentCharacterId: string,
    updatedStatBlock: StatBlock,
    hpDifference?: number
  ): void => {
    this.UpdatePersistentCharacter(persistentCharacterId, {
      StatBlock: updatedStatBlock,
      CurrentHP: updatedStatBlock.HP.Value - (hpDifference ?? 0)
    });
    this.tracker.Encounter.UpdatePersistentCharacterStatBlock(
      persistentCharacterId,
      updatedStatBlock
    );
  };

  public CreateAndEditSpell = (): void => {
    const newSpell = {
      ...Spell.Default(),
      Name: "New Spell",
      Source: "Custom",
      Id: probablyUniqueString()
    };
    this.tracker.EditSpell({
      spell: newSpell,
      onSave: this.libraries.Spells.SaveNewListing,
      onDelete: this.libraries.Spells.DeleteListing
    });
  };

  public EditSpell = (listing: Listing<Spell>): void => {
    listing.GetAsyncWithUpdatedId(spell => {
      this.tracker.EditSpell({
        spell: { ...Spell.Default(), ...spell },
        onSave: spell =>
          this.libraries.Spells.SaveEditedListing(listing, spell),
        onDelete: this.libraries.Spells.DeleteListing
      });
    });
  };

  public ReferenceSpell = (spellListing: Listing<Spell>): boolean => {
    const prompt = SpellPrompt(spellListing);
    this.tracker.PromptQueue.Add(prompt);
    return true;
  };

  public GetSpellsByNameRegex = ko.pureComputed(
    (): RegExp =>
      concatenatedStringRegex(
        this.libraries.Spells.GetAllListings() //TODO: Ensure that computed is updated with this
          .map(s => s.Meta().Name)
          .filter(n => n.length > 2)
      )
  );

  public LoadEncounter = (
    savedEncounter: EncounterState<CombatantState>
  ): void => {
    this.encounterCommander.LoadSavedEncounter(savedEncounter);
  };

  public SaveEncounter = (): void => {
    const prompt = SaveEncounterPrompt(
      this.tracker.Encounter.FullEncounterState(),
      this.tracker.Encounter.TemporaryBackgroundImageUrl(),
      this.libraries.Encounters.SaveNewListing,
      this.tracker.EventLog.AddEvent,
      _.uniq(this.libraries.Encounters.GetAllListings().map(e => e.Meta().Path))
    );
    this.tracker.PromptQueue.Add(prompt);
  };

  public MoveEncounter = async (
    encounterListing: Listing<SavedEncounter>
  ): Promise<void> => {
    const folderNames = _(this.libraries.Encounters.GetAllListings())
      .map(e => e.Meta().Path)
      .uniq()
      .compact()
      .value();
    const encounter = await encounterListing.GetWithTemplate(
      SavedEncounter.Default()
    );
    const prompt = MoveEncounterPrompt(
      encounter,
      (encounter: SavedEncounter, oldId: string) => {
        this.libraries.Encounters.DeleteListing(oldId);
        this.libraries.Encounters.SaveNewListing(encounter);
      },
      folderNames
    );
    this.tracker.PromptQueue.Add(prompt);
  };

  public ReferenceCondition = (conditionName: string): void => {
    const promptProps = ConditionReferencePrompt(conditionName);
    if (promptProps) {
      this.tracker.PromptQueue.Add(promptProps);
    }
  };

  public LaunchQuickAddPrompt = (): void => {
    this.encounterCommander.QuickAddStatBlock();
  };

  private deleteSavedStatBlock = (statBlockId: string) => (): void => {
    this.libraries.StatBlocks.DeleteListing(statBlockId);
    Metrics.TrackEvent("StatBlockDeleted", { Id: statBlockId });
  };

  private saveStatblockAsPersistentCharacter = (statBlock: StatBlock) => {
    const persistentCharacter = PersistentCharacter.Initialize(statBlock);
    this.libraries.PersistentCharacters.SaveNewListing(persistentCharacter);
  };
}


================================================
FILE: client/Commands/PromptQueue.test.ts
================================================
import { PromptQueue } from "./PromptQueue";
import { PromptProps } from "../Prompts/PendingPrompts";

function MockPrompt(): PromptProps<{}> {
  return {
    autoFocusSelector: "",
    children: null,
    initialValues: {},
    onSubmit: jest.fn(() => true)
  };
}

describe("PromptQueue", () => {
  it("Can list prompts", () => {
    const promptQueue = new PromptQueue();
    const prompt = MockPrompt();
    promptQueue.Add(prompt);
    expect(promptQueue.GetPrompts()[0][0]).toBe(prompt);
  });

  it("Does not call onSubmit when dismissing a prompt", () => {
    const promptQueue = new PromptQueue();
    const prompt = MockPrompt();
    const promptId = promptQueue.Add(prompt);
    promptQueue.Remove(promptId);
    expect(prompt.onSubmit).not.toHaveBeenCalled();
  });
});


================================================
FILE: client/Commands/PromptQueue.ts
================================================
import * as ko from "knockout";

import { probablyUniqueString } from "../../common/Toolbox";
import { PromptProps } from "../Prompts/PendingPrompts";

export class PromptQueue {
  constructor() {}

  private prompts = ko.observableArray<[PromptProps<any>, string]>();

  public Add = (prompt: PromptProps<any>) => {
    const promptId = probablyUniqueString();
    this.prompts.push([prompt, promptId]);
    return promptId;
  };

  public Remove = (promptId: string) =>
    this.prompts.remove(p => p[1] == promptId);

  public GetPrompts = ko.pureComputed(() => this.prompts());
}


================================================
FILE: client/Commands/ToggleFullscreen.ts
================================================
export function ToggleFullscreen() {
  if (!FullscreenSupported()) {
    return;
  }
  if (!document["fullscreenElement"]) {
    document.documentElement.requestFullscreen();
  } else if (document.exitFullscreen) {
    document.exitFullscreen();
  }
}

export function FullscreenSupported() {
  return typeof document.documentElement.requestFullscreen == "function";
}


================================================
FILE: client/Commands/Toolbar.test.tsx
================================================
import * as Enzyme from "enzyme";
import * as React from "react";

import { Button } from "../Components/Button";
import { Command } from "./Command";
import { Toolbar } from "./Toolbar";
import { CommandButton } from "./CommandButton";

const renderToolbarWithSingleCommand = (
  id,
  description,
  width: "narrow" | "wide" = "narrow"
) => {
  const encounterCommands = [
    new Command({
      id: id,
      description: description,
      actionBinding: () => {},
      fontAwesomeIcon: "gear"
    })
  ];

  return Enzyme.shallow(
    <Toolbar
      encounterCommands={encounterCommands}
      combatantCommands={[]}
      width={width}
      showCombatantCommands={true}
    />
  );
};

describe("Toolbar component", () => {
  test("Button shows the command's description and key binding", () => {
    const id = "start-encounter";
    const description = "Test command";
    const component = renderToolbarWithSingleCommand(id, description);

    const tooltip = component
      .find(CommandButton)
      .first()
      .dive()
      .find(Button)
      .prop("tooltip");
    expect(tooltip).toEqual(`${description} [alt+r]`);
  });

  test("Button shows the command's description if key binding is blank", () => {
    const id = "test-command";
    const description = "Test command";
    const component = renderToolbarWithSingleCommand(id, description);

    const tooltip = component
      .find(CommandButton)
      .first()
      .dive()
      .find(Button)
      .prop("tooltip");
    expect(tooltip).toEqual(description);
  });

  test("Button shows label when toolbar is wide", () => {
    const id = "test-command";
    const description = "Test command";
    const component = renderToolbarWithSingleCommand(id, description, "wide");

    const label = component
      .find(CommandButton)
      .first()
      .dive()
      .find(Button)
      .prop("text");
    expect(label).toEqual(description);
  });
});


================================================
FILE: client/Commands/Toolbar.tsx
================================================
import * as React from "react";
import { Command } from "./Command";
import { CommandButton } from "./CommandButton";

interface ToolbarProps {
  encounterCommands: Command[];
  combatantCommands: Command[];
  width: "narrow" | "wide";
  showCombatantCommands: boolean;
}

export function Toolbar(props: ToolbarProps) {
  const [widthStyle, setWidthStyle] = React.useState<string>("auto");

  const outerElement = React.useRef<HTMLDivElement>(null);
  const innerElement = React.useRef<HTMLDivElement>(null);

  React.useLayoutEffect(() => {
    if (!outerElement.current || !innerElement.current) {
      return;
    }
    //Force the scrollbar out of view
    const width =
      outerElement.current.offsetWidth +
      innerElement.current.offsetWidth -
      innerElement.current.clientWidth;
    setWidthStyle(width.toString() + "px");
  }, [innerElement, outerElement]);

  const className = `c-toolbar s-${props.width}`;

  const toCommandButton = (c: Command) => (
    <CommandButton key={c.Id} command={c} showLabel={props.width == "wide"} />
  );

  const encounterCommandButtons = props.encounterCommands.map(toCommandButton);
  const combatantCommandButtons = props.combatantCommands.map(toCommandButton);

  const style: React.CSSProperties =
    props.width == "narrow" ? { width: widthStyle } : {};

  return (
    <div className={className} ref={outerElement}>
      <div className="scrollframe" ref={innerElement} style={style}>
        <div className="commands-encounter">{encounterCommandButtons}</div>
        {props.showCombatantCommands && (
          <div className="commands-combatant">{combatantCommandButtons}</div>
        )}
      </div>
    </div>
  );
}


================================================
FILE: client/Components/Button.tsx
================================================
import Tippy, { TippyProps } from "@tippyjs/react";
import * as React from "react";
import { FieldProps, Field } from "formik";

export interface ButtonProps {
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
  onMouseOver?: React.MouseEventHandler<HTMLButtonElement>;

  additionalClassNames?: string;
  fontAwesomeIcon?: string;
  text?: string;
  tooltip?: string;
  tooltipProps?: Omit<TippyProps, "children" | "content">;

  type?: "button" | "submit";
  disabled?: boolean;
}

export function Button(props: ButtonProps): JSX.Element {
  const text = props.text || "";

  const classNames = ["c-button"];

  if (props.disabled) {
    classNames.push("c-button--disabled");
  }
  if (props.fontAwesomeIcon && props.text) {
    classNames.push("c-button--icon-and-text");
  }
  if (props.additionalClassNames) {
    classNames.push(props.additionalClassNames);
  }

  const faElement = props.fontAwesomeIcon && (
    <span className={`fas fa-${props.fontAwesomeIcon}`} />
  );

  const button = (
    <button
      type={props.type ?? "button"}
      className={classNames.join(" ")}
      onClick={props.disabled ? undefined : props.onClick}
      onMouseOver={props.disabled ? undefined : props.onMouseOver}
      tabIndex={props.disabled ? -1 : 0}
    >
      {faElement}
      {text}
    </button>
  );

  if (props.tooltip) {
    return (
      <Tippy content={props.tooltip} {...props.tooltipProps}>
        {button}
      </Tippy>
    );
  } else {
    return button;
  }
}

export function SubmitButton(
  props: ButtonProps & { submitIntent?: [string, any] }
) {
  const buttonProps: ButtonProps = {
    ...props,
    type: "submit",
    fontAwesomeIcon: props.fontAwesomeIcon ?? "check",
    onClick: props.onClick || (() => true)
  };

  if (props.submitIntent) {
    return (
      <Field>
        {(formik: FieldProps) => (
          <Button
            {...buttonProps}
            onClick={e => {
              if (buttonProps.disabled) {
                return;
              }
              formik.form.setFieldValue(
                props.submitIntent[0],
                props.submitIntent[1]
              );
              buttonProps.onClick(e);
            }}
          />
        )}
      </Field>
    );
  } else {
    return <Button {...buttonProps} />;
  }
}


================================================
FILE: client/Components/ErrorBoundary.tsx
================================================
import * as React from "react";

interface ErrorBoundaryProps {
  children: React.ReactNode;
  renderError: (error: Error, errorInfo: React.ErrorInfo) => React.ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
  errorInfo: React.ErrorInfo | null;
}

class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
    error: null,
    errorInfo: null
  };

  constructor(props: ErrorBoundaryProps) {
    super(props);
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    this.setState({
      hasError: true,
      error: error,
      errorInfo: errorInfo
    });
  }

  render() {
    if (this.state.hasError) {
      return this.props.renderError(this.state.error, this.state.errorInfo);
    }

    return this.props.children;
  }
}

export default ErrorBoundary;


================================================
FILE: client/Components/Info.tsx
================================================
import Tippy, { TippyProps } from "@tippyjs/react";
import * as React from "react";

export function Info(props: {
  children: React.ReactChild;
  tippyProps?: Omit<TippyProps, "content" | "children">;
}) {
  return (
    <Tippy content={props.children} {...props.tippyProps}>
      <i className="c-info fas fa-info-circle" />
    </Tippy>
  );
}


================================================
FILE: client/Components/LoadingIndicator.tsx
================================================
import * as React from "react";

export function LoadingIndicator() {
  return (
    <div className="loading-indicator">
      <img
        src="../img/boot-white-on-darkred-192.png"
        alt="Improved Initiative Flying Boot Logo"
      />
      <p>Loading...</p>
    </div>
  );
}


================================================
FILE: client/Components/Overlay.tsx
================================================
import * as React from "react";
import * as ReactDOM from "react-dom";

interface OverlayProps {
  maxHeightPx?: number;
  handleMouseEvents?: (e: React.MouseEvent<HTMLDivElement>) => void;
  left?: number;
  top?: number;
}

interface OverlayState {
  height: number;
}

export class Overlay extends React.Component<OverlayProps, OverlayState> {
  constructor(props: OverlayProps) {
    super(props);
    this.state = {
      height: null
    };
  }

  public render() {
    const overflowAmount = Math.max(
      (this.props.top || 0) + this.state.height - window.innerHeight + 4,
      0
    );
    const style: React.CSSProperties = {
      maxHeight: this.props.maxHeightPx || "100%",
      left: this.props.left || 0,
      top: this.props.top - overflowAmount || 0
    };

    return (
      <div
        className="c-overlay"
        style={style}
        onMouseEnter={this.props.handleMouseEvents}
        onMouseLeave={this.props.handleMouseEvents}
      >
        {this.props.children}
      </div>
    );
  }

  public componentDidMount() {
    this.updateHeight();
  }

  public componentDidUpdate() {
    this.updateHeight();
  }

  private updateHeight() {
    const domElement = ReactDOM.findDOMNode(this);
    if (domElement instanceof Element) {
      const newHeight = domElement.getBoundingClientRect().height;
      if (newHeight != this.state.height) {
        this.setState({
          height: newHeight
        });
      }
    }
  }
}


================================================
FILE: client/Components/StatBlock.test.tsx
================================================
import * as Enzyme from "enzyme";
import * as React from "react";

import { StatBlock } from "../../common/StatBlock";
import { StatBlockComponent } from "./StatBlock";

describe("StatBlock component", () => {
  test("Shows the statblock's name", () => {
    const component = Enzyme.render(
      <StatBlockComponent
        statBlock={{ ...StatBlock.Default(), Name: "Snarglebargle" }}
        displayMode="default"
      />
    );
    const headerText = component.find("h3").text();
    expect(headerText).toEqual("Snarglebargle");
  });
});


================================================
FILE: client/Components/StatBlock.tsx
================================================
import * as React from "react";
import { StatBlock } from "../../common/StatBlock";
import {
  TextEnricher,
  TextEnricherContext
} from "../TextEnricher/TextEnricher";
import { StatBlockHeader } from "./StatBlockHeader";
import { useContext } from "react";
import { LoadingIndicator } from "./LoadingIndicator";
import ErrorBoundary from "./ErrorBoundary";
import { DefaultRules } from "../Rules/Rules";
import { SettingsContext } from "../Settings/SettingsContext";

interface StatBlockProps {
  statBlock: StatBlock;
  displayMode: "default" | "active";
  hideName?: boolean;
  isLoading?: boolean;
}

export function StatBlockComponent(props: StatBlockProps) {
  return (
    <ErrorBoundary
      renderError={error => (
        <div className="c-statblock">
          <div>
            <p>There was a problem with this StatBlock:</p>
            <pre className="c-statblock__error">{error.toString()}</pre>
            <p>Please open it in the StatBlock Editor to check your JSON</p>
          </div>
        </div>
      )}
    >
      <StatBlockComponentNoError {...props} />
    </ErrorBoundary>
  );
}

function StatBlockComponentNoError(props: StatBlockProps) {
  const textEnricher = useContext(TextEnricherContext);
  const rules = new DefaultRules();
  const settings = useContext(SettingsContext);

  const statBlock = props.statBlock;

  const modifierTypes = [
    { name: "Saves", data: statBlock.Saves },
    { name: "Skills", data: statBlock.Skills }
  ];

  const keywordSetTypes = [
    { name: "Senses", data: statBlock.Senses },
    { name: "Damage Vulnerabilities", data: statBlock.DamageVulnerabilities },
    { name: "Damage Resistances", data: statBlock.DamageResistances },
    { name: "Damage Immunities", data: statBlock.DamageImmunities },
    { name: "Condition Immunities", data: statBlock.ConditionImmunities },
    { name: "Languages", data: statBlock.Languages }
  ];

  const powerTypes = [
    { name: "Traits", data: statBlock.Traits },
    { name: "Actions", data: statBlock.Actions },
    { name: "Bonus Actions", data: statBlock.BonusActions },
    { name: "Reactions", data: statBlock.Reactions },
    { name: "Legendary Actions", data: statBlock.LegendaryActions },
    { name: "Mythic Actions", data: statBlock.MythicActions }
  ];

  const headerEntries = (
    <>
      {props.hideName || (
        <StatBlockHeader
          name={statBlock.Name}
          imageUrl={statBlock.ImageURL}
          source={statBlock.Source}
          type={statBlock.Type}
        />
      )}

      <hr />
    </>
  );

  const statEntries = (
    <>
      <div className="AC">
        <span className="stat-label">Armor Class</span>
        <span className="stat-value">{statBlock.AC.Value}</span>
        <span className="notes">
          {textEnricher.EnrichText(statBlock.AC.Notes)}
        </span>
      </div>

      <div className="HP">
        <span className="stat-label">Hit Points</span>
        <span className="stat-value">{statBlock.HP.Value}</span>
        <span className="notes">
          {textEnricher.EnrichText(statBlock.HP.Notes)}
        </span>
      </div>

      <div className="speed">
        <span className="stat-label">Speed</span>
        <span className="stat-value">
          {statBlock.Speed.map((speed, i) => (
            <span className="stat-value__item" key={"stat-value__speed-" + i}>
              {speed}
            </span>
          ))}
        </span>
      </div>

      <div className="Abilities">
        {Object.keys(StatBlock.Default().Abilities).map(abilityName => {
          const abilityScore = statBlock.Abilities[abilityName];
          const abilityModifier =
            textEnricher.GetEnrichedModifierFromAbilityScore(abilityScore);
          return (
            <div className="Ability" key={abilityName}>
              <div className="stat-label">{abilityName}</div>
              <span className={"score " + abilityName}>{abilityScore}</span>
              <span className={"modifier " + abilityName}>
                {abilityModifier}
              </span>
            </div>
          );
        })}
      </div>

      {settings.StatBlock.CustomFields.length > 0 && (
        <div className="custom-fields">
          {settings.StatBlock.CustomFields.map(fieldSetting => {
            const field = statBlock.CustomFields?.find(
              f => f.Name === fieldSetting.name
            );
            return (
              <div className="custom-field" key={fieldSetting.name}>
                <span className="stat-label">{fieldSetting.name}</span>
                <span className="stat-value">
                  {field ? field.Content : fieldSetting.defaultValue}
                </span>
              </div>
            );
          })}
        </div>
      )}

      <hr />

      <div className="modifiers">
        {modifierTypes
          .filter(modifierType => modifierType.data.length > 0)
          .map(modifierType => (
            <div key={modifierType.name} className={modifierType.name}>
              <span className="stat-label">{modifierType.name}</span>
              {modifierType.data.map((modifier, i) => (
                <span className="stat-value" key={i + modifier.Name}>
                  {modifier.Name}
                  {textEnricher.EnrichModifier(modifier.Modifier)}{" "}
                </span>
              ))}
            </div>
          ))}
      </div>

      <div className="keyword-sets">
        {keywordSetTypes
          .filter(keywordSetType => keywordSetType.data.length > 0)
          .map(keywordSetType => (
            <div key={keywordSetType.name} className={keywordSetType.name}>
              <span className="stat-label">{keywordSetType.name}</span>
              <span className="stat-value">
                <span className="stat-value__item">
                  {keywordSetType.data.map((keyword, index) => {
                    return (
                      <span
                        className="stat-value__item"
                        key={`stat-value__${keywordSetType.name}-${index}`}
                      >
                        {keyword}
                      </span>
                    );
                  })}
                </span>
              </span>
            </div>
          ))}
      </div>

      {statBlock.Challenge && (
        <div className="Challenge">
          <span className="stat-label">
            {statBlock.Player == "player" ? "Level" : "Challenge"}
          </span>
          <span className="stat-value">{statBlock.Challenge}</span>
          <span className="stat-label">Proficiency Bonus</span>
          <span className="stat-value">
            +{rules.GetProficiencyBonus(statBlock.Challenge)}
          </span>
        </div>
      )}

      <hr />
    </>
  );

  const actionEntries = powerTypes
    .filter(powerType => powerType?.data?.length > 0)
    .map(powerType => (
      <div key={powerType.name} className={powerType.name}>
        <h4 className="stat-label">{powerType.name}</h4>
        {powerType.data.map((power, j) => (
          <div key={j + power.Name}>
            {power.Name?.length ? (
              <span className="stat-label">{power.Name}</span>
            ) : null}
            {power.Usage && <span className="stat-label">{power.Usage}</span>}
            <span className="power-content">
              {textEnricher.EnrichText(power.Content)}
            </span>
          </div>
        ))}
        <hr />
      </div>
    ));

  const description = statBlock.Description && (
    <div className="Description">
      {textEnricher.EnrichText(statBlock.Description)}
    </div>
  );

  let innerEntries;

  if (props.isLoading) {
    return (
      <div className="c-statblock">
        {headerEntries}
        <LoadingIndicator />
      </div>
    );
  }
  if (props.displayMode == "active") {
    innerEntries = (
      <>
        {actionEntries}
        {statEntries}
      </>
    );
  } else {
    innerEntries = (
      <>
        {statEntries}
        {actionEntries}
      </>
    );
  }
  return (
    <div className="c-statblock">
      {headerEntries}
      {innerEntries}
      {description}
    </div>
  );
}


================================================
FILE: client/Components/StatBlockHeader.tsx
================================================
import * as React from "react";

interface StatBlockHeaderProps {
  name: string;
  statBlockName?: string;
  type: string;
  source: string;
  imageUrl?: string;
}

interface StatBlockHeaderState {
  portraitSize: "thumbnail" | "full";
}

export class StatBlockHeader extends React.Component<
  StatBlockHeaderProps,
  StatBlockHeaderState
> {
  constructor(props) {
    super(props);
    this.state = { portraitSize: "thumbnail" };
  }

  public render() {
    const nameNeedsFallback =
      this.props.statBlockName &&
      this.props.name.indexOf(this.props.statBlockName) == -1;

    const statBlockName = (
      <span className="StatBlockName"> ({this.props.statBlockName})</span>
    );

    let header = (
      <div className="c-statblock-header">
        <h3 className="Name">
          {this.props.name}
          {nameNeedsFallback && statBlockName}
        </h3>
        <div className="Source">{this.props.source}</div>
        <div className="Type">{this.props.type}</div>
      </div>
    );

    if (this.props.imageUrl) {
      header = (
        <div
          className={`c-statblock-header__with-portrait--${this.state.portraitSize}`}
        >
          <img
            className={`c-statblock-header__portrait`}
            onClick={this.togglePortraitSize}
            src={this.props.imageUrl}
          />
          {header}
        </div>
      );
    }

    return header;
  }

  private togglePortraitSize = () => {
    if (this.state.portraitSize == "thumbnail") {
      this.setState({ portraitSize: "full" });
    } else {
      this.setState({ portraitSize: "thumbnail" });
    }
  };
}


================================================
FILE: client/Components/Tabs.tsx
================================================
import * as React from "react";

export function Tabs<TKey extends string>(props: {
  optionNamesById: Record<TKey, string>;
  selected?: TKey | string;
  onChoose: (option: TKey) => void;
}) {
  const buttonElements = Object.keys(props.optionNamesById).map(
    (key: TKey, i) => {
      const isSelected =
        props.selected == props.optionNamesById[key] || props.selected == key;
      return (
        <button
          type="button"
          key={key}
          className={isSelected ? "c-tab s-selected" : "c-tab"}
          onClick={() => props.onChoose(key)}
        >
          {props.optionNamesById[key]}
        </button>
      );
    }
  );

  return <div className="c-tabs">{buttonElements}</div>;
}


================================================
FILE: client/Encounter/AutoPopulatedNotes.ts
================================================
import { NameAndContent, StatBlock } from "../../common/StatBlock";

export function AutoPopulatedNotes(statBlock: StatBlock): string {
  const notes = [];
  let match = [];

  const spellcasting = statBlock.Traits.find(t => t.Name === "Spellcasting");
  if (spellcasting) {
    notes.push("Spellcasting Slots");
    const content = spellcasting.Content;

    const spellPattern = /([1-9])(st|nd|rd|th) level \(([1-9])/gm;
    while ((match = spellPattern.exec(content))) {
      notes.push(`${match[1]}${match[2]} Level [${match[3]}/${match[3]}]`);
    }
  }

  const innateSpellcasting = statBlock.Traits.find(
    t => t.Name === "Innate Spellcasting"
  );

  if (innateSpellcasting) {
    notes.push("Innate Spellcasting Slots");

    const content = innateSpellcasting.Content;

    const innatePattern = /(\d)\/day/gim;
    while ((match = innatePattern.exec(content))) {
      notes.push(`[${match[1]}/${match[1]}]`);
    }
  }

  if (statBlock.LegendaryActions.length > 0) {
    notes.push("Legendary Actions [3/3]");
  }

  notes.push(...GetDailyCounters(statBlock.Traits));
  notes.push(...GetRechargeCounters(statBlock.Traits));

  notes.push(...GetDailyCounters(statBlock.Actions));
  notes.push(...GetRechargeCounters(statBlock.Actions));

  notes.push(...GetDailyCounters(statBlock.Reactions));
  notes.push(...GetRechargeCounters(statBlock.Reactions));

  if (statBlock.BonusActions) {
    notes.push(...GetDailyCounters(statBlock.BonusActions));
    notes.push(...GetRechargeCounters(statBlock.BonusActions));
  }

  return notes.join("\n\n");
}

function GetDailyCounters(statBlockEntries: NameAndContent[]) {
  const perDayPattern = /\((\d)\/day\)/gim;
  return statBlockEntries
    .filter(t => t.Name.match(perDayPattern))
    .map(t => `${t.Name.replace(perDayPattern, "[$1/$1]")}`);
}

function GetRechargeCounters(statBlockEntries: NameAndContent[]) {
  return statBlockEntries
    .filter(t => t.Name.includes("(Recharge"))
    .map(t => `${t.Name.replace(/\(.*?\)/, "")}[1/1]`);
}


================================================
FILE: client/Encounter/AutoRerollInitiativeOption.test.ts
================================================
import { AutoRerollInitiativeOption } from "../../common/Settings";
import { StatBlock } from "../../common/StatBlock";
import { InitializeTestSettings } from "../test/InitializeTestSettings";
import { buildEncounter } from "../test/buildEncounter";

describe("AutoRerollInitiativeOption", () => {
  beforeEach(() => {
    InitializeTestSettings();
  });

  const runEncounter = promptReroll => {
    const encounter = buildEncounter();
    const combatant1 = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    const combatant2 = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    combatant1.Initiative(10);
    combatant2.Initiative(5);
    encounter.EncounterFlow.StartEncounter();
    encounter.EncounterFlow.NextTurn(promptReroll);
    encounter.EncounterFlow.NextTurn(promptReroll);
    return encounter;
  };

  test("Default", () => {
    const promptReroll = jest.fn();
    runEncounter(promptReroll);
    expect(promptReroll).not.toBeCalled();
  });

  test("Prompt", () => {
    InitializeTestSettings({
      Rules: {
        AutoRerollInitiative: AutoRerollInitiativeOption.Prompt
      }
    });
    const promptReroll = jest.fn();
    runEncounter(promptReroll);
    expect(promptReroll).toBeCalled();
  });

  test("Automatic", () => {
    InitializeTestSettings({
      Rules: {
        AutoRerollInitiative: AutoRerollInitiativeOption.Automatic
      }
    });
    const promptReroll = jest.fn();
    Math.random = () => 1;
    const encounter = runEncounter(promptReroll);
    expect(encounter.Combatants()[0].Initiative()).toEqual(20);
    expect(encounter.Combatants()[1].Initiative()).toEqual(20);
  });
});


================================================
FILE: client/Encounter/Encounter.test.ts
================================================
import { buildEncounter } from "../test/buildEncounter";

import { StatBlock } from "../../common/StatBlock";
import { Tag } from "../Combatant/Tag";
import { InitializeTestSettings } from "../test/InitializeTestSettings";
import { GetTimerReadout } from "../Widgets/GetTimerReadout";
import { Encounter } from "./Encounter";

describe("Encounter", () => {
  let encounter: Encounter;
  beforeEach(() => {
    InitializeTestSettings();
    encounter = buildEncounter();
  });

  test("A new Encounter has no combatants", () => {
    expect(encounter.Combatants().length).toBe(0);
  });

  test("Adding a statblock results in a combatant", () => {
    const statBlock = StatBlock.Default();
    encounter.AddCombatantFromStatBlock(statBlock);
    expect(encounter.Combatants().length).toBe(1);
    expect(encounter.Combatants()[0].StatBlock()).toEqual(statBlock);
  });

  test("Combat should not be active", () => {
    expect(encounter.EncounterFlow.State()).toBe("inactive");
  });

  test("NextTurn changes the active combatant and will return to the top of the initiative order", () => {
    const combatant1 = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    const combatant2 = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    combatant1.Initiative(10);
    combatant2.Initiative(5);
    encounter.EncounterFlow.StartEncounter();

    const promptReroll = jest.fn();
    expect(encounter.EncounterFlow.ActiveCombatant()).toBe(
      encounter.Combatants()[0]
    );
    encounter.EncounterFlow.NextTurn(promptReroll);
    expect(encounter.EncounterFlow.ActiveCombatant()).toBe(
      encounter.Combatants()[1]
    );
    encounter.EncounterFlow.NextTurn(promptReroll);
    expect(encounter.EncounterFlow.ActiveCombatant()).toBe(
      encounter.Combatants()[0]
    );
    expect(promptReroll).not.toBeCalled();
  });

  test("Display post-combat stats produces reasonable results", () => {
    jest.useFakeTimers();

    for (let i = 0; i < 2; i++) {
      const thisCombatant = encounter.AddCombatantFromStatBlock(
        StatBlock.Default()
      );
      thisCombatant.Initiative(2 - i);
      thisCombatant.Alias(`Combatant ${i}`);
    }

    encounter.EncounterFlow.StartEncounter();

    for (let i = 0; i < 5; i++) {
      jest.advanceTimersByTime(60 * 1000);
      encounter.EncounterFlow.NextTurn(jest.fn());
    }

    expect(
      GetTimerReadout(encounter.EncounterFlow.CombatTimer.ElapsedSeconds())
    ).toBe("5:00");

    const combatant0Elapsed = encounter
        .Combatants()[0]
        .CombatTimer.ElapsedSeconds(),
      combatant0Rounds = encounter.Combatants()[0].CombatTimer.ElapsedRounds();

    expect(GetTimerReadout(combatant0Elapsed / combatant0Rounds)).toBe("1:00");

    const combatant1Elapsed = encounter
        .Combatants()[1]
        .CombatTimer.ElapsedSeconds(),
      combatant1Rounds = encounter.Combatants()[1].CombatTimer.ElapsedRounds();

    expect(GetTimerReadout(combatant1Elapsed / combatant1Rounds)).toBe("0:40");
  });

  test("Should properly populate beancounters for monsters", () => {
    const combatant = encounter.AddCombatantFromStatBlock({
      ...StatBlock.Default(),
      Traits: [
        {
          Name: "Spellcasting",
          Content:
            "• 1st level (4 slots): spell1, spell2\n• 2nd level (3 slots): spell3, spell4"
        },
        {
          Name: "Innate Spellcasting",
          Content: "3/day each: spell1, spell2\n1/day each: spell4, spell5"
        }
      ],
      Actions: [
        {
          Name: "Thrice Daily Action (3/Day)",
          Content: ""
        },
        {
          Name: "Recharge Action (Recharge 5-6)",
          Content: ""
        }
      ],
      LegendaryActions: [
        {
          Name: "",
          Content: ""
        }
      ],
      Player: ""
    });

    expect(combatant.CurrentNotes()).toBe(
      "Spellcasting Slots\n\n1st Level [4/4]\n\n2nd Level [3/3]\n\n" +
        "Innate Spellcasting Slots\n\n[3/3]\n\n[1/1]\n\n" +
        "Legendary Actions [3/3]\n\n" +
        "Thrice Daily Action [3/3]\n\n" +
        "Recharge Action [1/1]"
    );
  });

  describe("Initiative Ordering", () => {
    test("By roll", () => {
      const slow = encounter.AddCombatantFromStatBlock(StatBlock.Default());
      const fast = encounter.AddCombatantFromStatBlock(StatBlock.Default());
      expect(encounter.Combatants()).toEqual([slow, fast]);

      fast.Initiative(20);
      slow.Initiative(1);
      encounter.EncounterFlow.StartEncounter();
      expect(encounter.Combatants()).toEqual([fast, slow]);
    });

    test("By modifier", () => {
      const slow = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        InitiativeModifier: 0
      });
      const fast = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        InitiativeModifier: 2
      });
      encounter.EncounterFlow.StartEncounter();
      expect(encounter.Combatants()).toEqual([fast, slow]);
    });

    test("By group modifier", () => {
      const slow = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        InitiativeModifier: 0
      });
      const fast = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        InitiativeModifier: 2
      });
      const loner = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        InitiativeModifier: 1
      });
      slow.InitiativeGroup("group");
      fast.InitiativeGroup("group");
      encounter.EncounterFlow.StartEncounter();

      expect(encounter.Combatants()).toEqual([fast, slow, loner]);
    });

    test("Favor player characters", () => {
      const creature = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default()
      });
      const playerCharacter = encounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        Player: "player"
      });
      encounter.EncounterFlow.StartEncounter();
      expect(encounter.Combatants()).toEqual([playerCharacter, creature]);
    });
  });

  test("ActiveCombatantOnTop shows player view combatants in shifted order", () => {
    InitializeTestSettings({
      PlayerView: {
        ActiveCombatantOnTop: true
      }
    });

    for (let i = 0; i < 5; i++) {
      const thisCombatant = encounter.AddCombatantFromStatBlock(
        StatBlock.Default()
      );
      thisCombatant.Initiative(i);
    }

    encounter.EncounterFlow.StartEncounter();
    expect(encounter.GetPlayerView().Combatants[0].Id).toBe(
      encounter.EncounterFlow.ActiveCombatant().Id
    );

    for (let i = 0; i < 5; i++) {
      encounter.EncounterFlow.NextTurn(jest.fn());
      expect(encounter.GetPlayerView().Combatants[0].Id).toBe(
        encounter.EncounterFlow.ActiveCombatant().Id
      );
    }
  });

  test("Encounter turn timer stops when encounter ends", () => {
    jest.useFakeTimers();
    encounter.AddCombatantFromStatBlock({
      ...StatBlock.Default(),
      HP: { Value: 10, Notes: "" },
      Player: "player"
    });
    encounter.EncounterFlow.StartEncounter();
    jest.advanceTimersByTime(10000); // 10 seconds
    encounter.EncounterFlow.EndEncounter();
    expect(encounter.EncounterFlow.TurnTimerReadout()).toBe("0:00");
  });
});

describe("Tags", () => {
  beforeEach(() => {
    InitializeTestSettings();
  });

  test("Should appear in Player View", () => {
    const encounter = buildEncounter();
    const combatant = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    combatant.Tags.push(new Tag("Some Tag", combatant, false));
    const playerView = encounter.GetPlayerView();
    const playerViewCombatant = playerView.Combatants[0];
    expect(playerViewCombatant.Tags).toEqual([
      {
        Text: "Some Tag",
        DurationRemaining: -1,
        DurationTiming: "StartOfTurn",
        DurationCombatantId: ""
      }
    ]);
  });

  test("Should not appear in Player View when hidden", () => {
    const encounter = buildEncounter();
    const combatant = encounter.AddCombatantFromStatBlock(StatBlock.Default());
    combatant.Tags.push(new Tag("Some Tag", combatant, true));
    const playerView = encounter.GetPlayerView();
    const playerViewCombatant = playerView.Combatants[0];
    expect(playerViewCombatant.Tags).toEqual([]);
  });
});

describe("LoadEncounterState", () => {
  test("Should load combatants in order", () => {
    const baseEncounter = buildEncounter();

    for (const initiative of [10, 5, 15]) {
      const combatant = baseEncounter.AddCombatantFromStatBlock({
        ...StatBlock.Default(),
        Name: "Initiative " + initiative
      });
      combatant.Initiative(initiative);
    }

    baseEncounter.EncounterFlow.StartEncounter();

    expect(baseEncounter.Combatants().map(c => c.Initiative())).toEqual([
      15, 10, 5
    ]);
    expect(baseEncounter.EncounterFlow.State()).toEqual("active");

    const encounterState = baseEncounter.ObservableEncounterState();
    const encounter = buildEncounter();
    encounter.LoadEncounterState(encounterState, () => {}, null);

    expect(encounter.Combatants().map(c => c.Initiative())).toEqual([
      15, 10, 5
    ]);
    expect(encounter.EncounterFlow.State()).toEqual("active");
  });
});


================================================
FILE: client/Encounter/Encounter.ts
================================================
import * as ko from "knockout";
import { find, max, sortBy } from "lodash";
import * as React from "react";
import * as Sentry from "@sentry/browser";

import * as _ from "lodash";

import { CombatStats } from "../../common/CombatStats";
import { CombatantState } from "../../common/CombatantState";
import { EncounterState } from "../../common/EncounterState";
import { PersistentCharacter } from "../../common/PersistentCharacter";
import { PlayerViewCombatantState } from "../../common/PlayerViewCombatantState";
import { StatBlock } from "../../common/StatBlock";
import { probablyUniqueString } from "../../common/Toolbox";
import { Combatant } from "../Combatant/Combatant";
import {
  GetOrRollMaximumHP,
  VariantMaximumHP
} from "../Combatant/GetOrRollMaximumHP";
import { ToPlayerViewCombatantState } from "../Combatant/ToPlayerViewCombatantState";
import { env } from "../Environment";
import { PlayerViewClient } from "../PlayerView/PlayerViewClient";
import { IRules } from "../Rules/Rules";
import { CurrentSettings } from "../Settings/Settings";
import { LegacySynchronousLocalStore } from "../Utility/LegacySynchronousLocalStore";
import {
  DifficultyCalculator,
  EncounterDifficulty
} from "../Widgets/DifficultyCalculator";
import { EncounterFlow } from "./EncounterFlow";
import { UpdatePersistentCharacter } from "../Library/Libraries";
import { Library } from "../Library/useLibrary";
import axios from "axios";
import { AutoPopulatedNotes } from "./AutoPopulatedNotes";
import { ConvertStringsToNumbersWhereNeeded } from "../StatBlockEditor/ConvertStringsToNumbersWhereNeeded";

export class Encounter {
  public TemporaryBackgroundImageUrl = ko.observable<string>(null);

  private lastVisibleActiveCombatantId: string | null = null;

  constructor(
    private playerViewClient: PlayerViewClient,
    private promptEditCombatantInitiative: (combatantId: string) => void,
    public Rules: IRules
  ) {
    this.Difficulty = ko.pureComputed(() => {
      const enemyChallengeRatings = this.combatants()
        .filter(c => !c.IsPlayerCharacter())
        .filter(c => c.StatBlock().Challenge)
        .map(c => c.StatBlock().Challenge.toString());
      const playerLevels = this.combatants()
        .filter(c => c.IsPlayerCharacter())
        .filter(c => c.StatBlock().Challenge)
        .map(c => c.StatBlock().Challenge.toString());
      return DifficultyCalculator.Calculate(
        enemyChallengeRatings,
        playerLevels
      );
    });

    this.GetPlayerView.subscribe(newPlayerView => {
      if (!this.playerViewClient) {
        return;
      }
      this.playerViewClient.UpdateEncounter(env.EncounterId, newPlayerView);
    });
  }

  private combatants = ko.observableArray<Combatant>([]);
  public Combatants = ko.pureComputed(() => this.combatants());
  public CombatantCountsByName: KnockoutObservable<{
    [name: string]: number;
  }> = ko.observable({});
  public ActiveCombatantStatBlock: KnockoutComputed<React.ReactElement<any>>;
  public Difficulty: ko.PureComputed<EncounterDifficulty>;

  public EncounterFlow = new EncounterFlow(this);

  private getGroupBonusForCombatant(combatant: Combatant) {
    if (combatant.InitiativeGroup() == null) {
      return combatant.InitiativeBonus();
    }

    const groupBonuses = this.combatants()
      .filter(c => c.InitiativeGroup() == combatant.InitiativeGroup())
      .map(c => c.InitiativeBonus());

    return max(groupBonuses) || combatant.InitiativeBonus();
  }

  private getCombatantSortIteratees(
    stable: boolean
  ): ((c: Combatant) => number | string)[] {
    if (stable) {
      return [c => -c.Initiative()];
    } else {
      return [
        c => -c.Initiative(),
        c => -this.getGroupBonusForCombatant(c),
        c => -c.InitiativeBonus(),
        c => (c.IsPlayerCharacter() ? 0 : 1),
        c => c.InitiativeGroup(),
        c => c.StatBlock().Name,
        c => c.IndexLabel()
      ];
    }
  }

  public SortByInitiative = (stable = false) => {
    const sortedCombatants = sortBy(
      this.combatants(),
      this.getCombatantSortIteratees(stable)
    );
    this.combatants(sortedCombatants);
  };

  public ImportEncounter = encounter => {
    const deepMerge = (a, b) => _.extend(true, {}, a, b);
    const defaultAdd = statBlock => {
      if (statBlock.TotalInitiativeModifier !== undefined) {
        statBlock.InitiativeModifier = statBlock.TotalInitiativeModifier;
      }
      const mergedStatBlock = deepMerge(StatBlock.Default(), statBlock);
      ConvertStringsToNumbersWhereNeeded(mergedStatBlock);
      this.AddCombatantFromStatBlock(mergedStatBlock);
    };
    if (encounter.Combatants) {
      encounter.Combatants.forEach(c => {
        if (c.Player == "npc") {
          c.Player = "";
        }

        const statBlock = c.StatBlock ?? c;

        if (c.Id) {
          axios
            .get(`/statblocks/${c.Id}`)
            .then(r => r.data)
            .then(statBlockFromLibrary => {
              const modifiedStatBlockFromLibrary = deepMerge(
                statBlockFromLibrary,
                statBlock
              );
              ConvertStringsToNumbersWhereNeeded(modifiedStatBlockFromLibrary);
              this.AddCombatantFromStatBlock(modifiedStatBlockFromLibrary);
            })
            .catch(_ => defaultAdd(statBlock));
        } else {
          defaultAdd(statBlock);
        }
      });
    }
  };

  public AddCombatantFromState = (combatantState: CombatantState) => {
    if (this.combatants().some(c => c.Id == combatantState.Id)) {
      combatantState.Id = probablyUniqueString();
    }

    const combatant = new Combatant(combatantState, this);
    this.combatants.push(combatant);

    combatant.UpdateIndexLabel();

    if (this.EncounterFlow.State() === "active") {
      this.promptEditCombatantInitiative(combatant.Id);
    }

    combatant.Tags().forEach(tag => {
      if (tag.HasDuration) {
        this.EncounterFlow.AddDurationTag(tag);
      }
    });

    return combatant;
  };

  public AddCombatantFromStatBlock = (
    statBlockJson: Record<string, unknown>,
    hideOnAdd = false,
    variantMaximumHP: VariantMaximumHP = VariantMaximumHP.DEFAULT
  ): void => {
    try {
      const statBlock: StatBlock = { ...StatBlock.Default(), ...statBlockJson };
      statBlock.HP = {
        ...statBlock.HP,
        Value: GetOrRollMaximumHP(statBlock, variantMaximumHP)
      };

      const initialState: CombatantState = {
        Id: probablyUniqueString(),
        StatBlock: statBlock,
        Alias: "",
        IndexLabel: null,
        CurrentHP: statBlock.HP.Value,
        CurrentNotes: AutoPopulatedNotes(statBlock),
        TemporaryHP: 0,
        Hidden: hideOnAdd,
        RevealedAC: false,
        Initiative: 0,
        Tags: [],
        RoundCounter: 0,
        ElapsedSeconds: 0,
        InterfaceVersion: process.env.VERSION || "unknown"
      };

      this.AddCombatantFromState(initialState);
    } catch (e) {
      console.warn("Couldn't add statblock: " + e);
      console.warn(JSON.stringify(statBlockJson));
      Sentry.captureException(e);
      Sentry.captureMessage(JSON.stringify(statBlockJson));
    }
  };

  public CanAddCombatant = (persistentCharacterId: string) => {
    return !this.combatants().some(
      c => c.PersistentCharacterId == persistentCharacterId
    );
  };

  public AddCombatantFromPersistentCharacter(
    persistentCharacter: PersistentCharacter,
    updatePersistentCharacter: UpdatePersistentCharacter,
    hideOnAdd = false,
    initiativeValue = 0
  ): Combatant | null {
    if (!this.CanAddCombatant(persistentCharacter.Id)) {
      return null;
    }

    const initialState: CombatantState = {
      Id: probablyUniqueString(),
      PersistentCharacterId: persistentCharacter.Id,
      StatBlock: persistentCharacter.StatBlock,
      Alias: "",
      IndexLabel: null,
      CurrentHP: persistentCharacter.CurrentHP,
      CurrentNotes: persistentCharacter.Notes,
      TemporaryHP: 0,
      Hidden: hideOnAdd,
      RevealedAC: false,
      Initiative: initiativeValue,
      Tags: [],
      RoundCounter: 0,
      ElapsedSeconds: 0,
      InterfaceVersion: persistentCharacter.Version
    };

    const combatant = this.AddCombatantFromState(initialState);

    combatant.AttachToPersistentCharacterLibrary(updatePersistentCharacter);

    return combatant;
  }

  public RemoveCombatant = (combatant: Combatant) => {
    combatant.IsPendingRemoval(true);
  };

  private flushCombatant = (combatant: Combatant) => {
    if (!combatant.IsPendingRemoval()) {
      console.warn("Cannot flush combatant that is not pending removal");
      return;
    }
    this.combatants.remove(combatant);

    const removedCombatantName = combatant.StatBlock().Name;
    const remainingCombatants = this.combatants();

    const allMyFriendsAreGone = remainingCombatants.every(
      c => c.StatBlock().Name != removedCombatantName
    );

    if (allMyFriendsAreGone) {
      const combatantCountsByName = this.CombatantCountsByName();
      delete combatantCountsByName[removedCombatantName];
      this.CombatantCountsByName(combatantCountsByName);
    }

    if (this.combatants().length == 0) {
      this.EncounterFlow.EndEncounter();
    }
  };

  public CombatantsPendingRemove = ko.computed(() =>
    this.combatants().filter(c => c.IsPendingRemoval())
  );

  public FlushCombatants = () => {
    for (const combatant of this.combatants()) {
      if (combatant.IsPendingRemoval()) {
        this.flushCombatant(combatant);
      }
    }
  };

  public RestoreCombatants = () => {
    for (const combatant of this.combatants()) {
      if (combatant.IsPendingRemoval()) {
        combatant.IsPendingRemoval(false);
      }
    }
  };

  public UpdatePersistentCharacterStatBlock(
    persistentCharacterId: string,
    newStatBlock: StatBlock
  ) {
    const combatant = find(
      this.combatants(),
      c => c.PersistentCharacterId == persistentCharacterId
    );
    if (!combatant) {
      return;
    }
    combatant.StatBlock(newStatBlock);
  }

  public MoveCombatant(combatant: Combatant, index: number) {
    combatant.InitiativeGroup(null);
    this.CleanInitiativeGroups();
    const currentPosition = this.combatants().indexOf(combatant);
    const passedCombatant = this.combatants()[index];
    const initiative = combatant.Initiative();
    let newInitiative = initiative;
    if (
      index > currentPosition &&
      passedCombatant &&
      passedCombatant.Initiative() < initiative
    ) {
      newInitiative = passedCombatant.Initiative();
    }
    if (
      index < currentPosition &&
      passedCombatant &&
      passedCombatant.Initiative() > initiative
    ) {
      newInitiative = passedCombatant.Initiative();
    }

    this.combatants.remove(combatant);
    this.combatants.splice(index, 0, combatant);
    combatant.Initiative(newInitiative);
    return newInitiative;
  }

  public CleanInitiativeGroups() {
    const combatants = this.combatants();
    combatants.forEach(combatant => {
      const group = combatant.InitiativeGroup();
      if (
        group &&
        combatants.filter(c => c.InitiativeGroup() === group).length < 2
      ) {
        combatant.InitiativeGroup(null);
      }
    });
  }

  public StartEncounterAutosaves = () => {
    this.ObservableEncounterState.subscribe(_ => {
      //Short circuit this observable so we don't save when seconds update
      const newState = this.FullEncounterState();

      LegacySynchronousLocalStore.Save<EncounterState<CombatantState>>(
        LegacySynchronousLocalStore.AutoSavedEncounters,
        LegacySynchronousLocalStore.DefaultSavedEncounterId,
        newState
      );
    });
  };

  public ObservableEncounterState = ko.computed(
    (): EncounterState<CombatantState> => {
      const activeCombatant = this.EncounterFlow.ActiveCombatant();

      return {
        ActiveCombatantId: activeCombatant ? activeCombatant.Id : null,
        RoundCounter: this.EncounterFlow.CombatTimer.ElapsedRounds(),
        //ElapsedSeconds: omitted to avoid repeated re-renders,
        Combatants: this.combatants()
          .filter(c => !c.IsPendingRemoval())
          .map<CombatantState>(c => c.GetState()),
        BackgroundImageUrl: this.TemporaryBackgroundImageUrl()
      };
    }
  );

  public FullEncounterState = ko.computed(
    (): EncounterState<CombatantState> => {
      return {
        ...this.ObservableEncounterState(),
        ElapsedSeconds: this.EncounterFlow.TurnTimer.ElapsedSeconds(),
        Combatants: this.combatants().map<CombatantState>(c => {
          const combatantState = c.GetState();
          combatantState.ElapsedSeconds = c.CombatTimer.ElapsedSeconds();
          return combatantState;
        })
      };
    }
  );

  public GetPlayerView = ko.computed(
    (): EncounterState<PlayerViewCombatantState> => {
      const activeCombatantId = this.getPlayerViewActiveCombatantId();
      const defaultBackgroundUrl =
        CurrentSettings().PlayerView.CustomStyles.backgroundUrl;
      return {
        ActiveCombatantId: activeCombatantId,
        RoundCounter: this.EncounterFlow.CombatTimer.ElapsedRounds(),
        Combatants: this.getCombatantsForPlayerView(activeCombatantId),
        BackgroundImageUrl:
          this.TemporaryBackgroundImageUrl() || defaultBackgroundUrl
      };
    }
  );

  public LoadEncounterState = (
    encounterState: EncounterState<CombatantState>,
    updatePersistentCharacter: UpdatePersistentCharacter,
    persistentCharacterLibrary: Library<PersistentCharacter>
  ) => {
    const combatantsInLabelOrder = _.sortBy(
      encounterState.Combatants,
      c => c.IndexLabel
    );
    combatantsInLabelOrder.forEach(async savedCombatant => {
      const combatant = this.AddCombatantFromState(savedCombatant);

      if (combatant.PersistentCharacterId !== null) {
        const fallback = PersistentCharacter.Initialize(combatant.StatBlock());
        const persistentCharacterListing =
          await persistentCharacterLibrary.GetOrCreateListingById(
            combatant.PersistentCharacterId,
            fallback
          );
        const persistentCharacter =
          await persistentCharacterListing.GetWithTemplate(fallback);

        combatant.StatBlock(persistentCharacter.StatBlock);
        combatant.CurrentHP(persistentCharacter.CurrentHP);
        combatant.CurrentNotes(persistentCharacter.Notes);
        combatant.AttachToPersistentCharacterLibrary(updatePersistentCharacter);
      }
    });

    const activeCombatant = _.find(
      this.combatants(),
      c => c.Id == encounterState.ActiveCombatantId
    );

    if (activeCombatant !== undefined) {
      this.EncounterFlow.State("active");
      this.EncounterFlow.ActiveCombatant(activeCombatant);
      this.EncounterFlow.ActiveCombatant().CombatTimer.Start();
      this.EncounterFlow.TurnTimer.Start();
      this.EncounterFlow.CombatTimer.Start();
      this.SortByInitiative();
    }

    this.EncounterFlow.CombatTimer.SetElapsedRounds(
      encounterState.RoundCounter || 1
    );
    this.EncounterFlow.CombatTimer.SetElapsedSeconds(
      encounterState.ElapsedSeconds || 0
    );
    this.TemporaryBackgroundImageUrl(encounterState.BackgroundImageUrl || null);
  };

  public ClearEncounter = () => {
    this.combatants([]);
    this.CombatantCountsByName({});
    this.EncounterFlow.EndEncounter();
  };

  private getPlayerViewActiveCombatantId() {
    const activeCombatant = this.EncounterFlow.ActiveCombatant();
    if (!activeCombatant) {
      this.lastVisibleActiveCombatantId = null;
      return this.lastVisibleActiveCombatantId;
    }

    if (activeCombatant.Hidden()) {
      return this.lastVisibleActiveCombatantId;
    }

    this.lastVisibleActiveCombatantId = activeCombatant.Id;

    return this.lastVisibleActiveCombatantId;
  }

  private getCombatantsForPlayerView(activeCombatantId: string | null) {
    const hideMonstersOutsideEncounter =
      CurrentSettings().PlayerView.HideMonstersOutsideEncounter;

    const combatants = this.combatants().slice();

    const activeCombatantOnTop =
      CurrentSettings().PlayerView.ActiveCombatantOnTop;
    if (activeCombatantOnTop && activeCombatantId && combatants.length > 0) {
      let combatantsMoved = 0;
      while (
        combatants[0].Id != activeCombatantId &&
        combatantsMoved < combatants.length //prevent infinite loop in case we can't find active combatant
      ) {
        combatants.push(combatants.shift() as Combatant);
        combatantsMoved++;
      }
    }

    const visibleCombatants = combatants.filter(c => {
      if (c.Hidden()) {
        return false;
      }
      if (c.IsPendingRemoval()) {
        return false;
      }

      if (
        hideMonstersOutsideEncounter &&
        this.EncounterFlow.State() == "inactive" &&
        !c.IsPlayerCharacter()
      ) {
        return false;
      }
      return true;
    });

    return visibleCombatants.map<PlayerViewCombatantState>(c =>
      ToPlayerViewCombatantState(c)
    );
  }

  public DisplayPlayerViewCombatStats(stats: CombatStats) {
    this.playerViewClient.DisplayCombatStats(env.EncounterId, stats);
  }
}


================================================
FILE: client/Encounter/EncounterFlow.ts
================================================
import * as ko from "knockout";

import { AutoRerollInitiativeOption } from "../../common/Settings";
import { Combatant } from "../Combatant/Combatant";
import { Tag } from "../Combatant/Tag";
import { CurrentSettings } from "../Settings/Settings";
import { CombatTimer } from "../Widgets/CombatTimer";
import { GetTimerReadout } from "../Widgets/GetTimerReadout";
import { Encounter } from "./Encounter";

export class EncounterFlow {
  public ActiveCombatant: KnockoutObservable<Combatant> =
    ko.observable<Combatant>();
  public TurnTimer = new CombatTimer();
  public CombatTimer = new CombatTimer();
  public State: KnockoutObservable<"active" | "inactive"> = ko.observable<
    "active" | "inactive"
  >("inactive");

  private durationTags: Tag[] = [];

  constructor(private encounter: Encounter) {}

  public StateIcon = ko.pureComputed(() =>
    this.State() === "active" ? "fa-play" : "fa-pause"
  );
  public StateTip = ko.pureComputed(() =>
    this.State() === "active" ? "Encounter Active" : "Encounter Inactive"
  );

  public TurnTimerReadout = ko.pureComputed(() =>
    GetTimerReadout(this.TurnTimer.ElapsedSeconds())
  );

  public StartEncounter = () => {
    if (this.encounter.Combatants().length == 0) {
      console.warn("Can't start an encounter with no combatants");
      return;
    }
    this.encounter.SortByInitiative();
    if (this.State() == "inactive") {
      this.CombatTimer.SetElapsedRounds(1);
    }
    this.State("active");
    this.ActiveCombatant(this.encounter.Combatants()[0]);
    this.ActiveCombatant().CombatTimer.Start();
    this.ActiveCombatant().CombatTimer.IncrementCombatRounds();
    this.TurnTimer.Start();
    this.CombatTimer.Start();
  };

  public EndEncounter = () => {
    this.State("inactive");

    if (this.ActiveCombatant() != null) {
      this.ActiveCombatant().CombatTimer.Pause();
    }

    this.CombatTimer.Pause();
    this.TurnTimer.Stop();
    this.ActiveCombatant(null);
    this.encounter.TemporaryBackgroundImageUrl(null);
  };

  public NextTurn = async (promptRerollInitiative: () => Promise<void>) => {
    const activeCombatant = this.ActiveCombatant();

    this.durationTags
      .filter(
        t =>
          t.HasDuration &&
          t.DurationCombatantId == activeCombatant.Id &&
          t.DurationTiming == "EndOfTurn"
      )
      .forEach(t => t.Decrement());

    let nextIndex = this.encounter.Combatants().indexOf(activeCombatant) + 1;
    if (nextIndex >= this.encounter.Combatants().length) {
      nextIndex = 0;
      const autoRerollOption = CurrentSettings().Rules.AutoRerollInitiative;
      if (autoRerollOption == AutoRerollInitiativeOption.Prompt) {
        await promptRerollInitiative();
      }
      if (autoRerollOption == AutoRerollInitiativeOption.Automatic) {
        this.rerollInitiativeWithoutPrompt();
      }
      this.CombatTimer.IncrementCombatRounds();
    }

    const nextCombatant = this.encounter.Combatants()[nextIndex];
    this.ActiveCombatant(nextCombatant);

    activeCombatant.CombatTimer.Pause();
    nextCombatant.CombatTimer.IncrementCombatRounds();
    nextCombatant.CombatTimer.Start();

    this.durationTags
      .filter(
        t =>
          t.HasDuration &&
          t.DurationCombatantId == nextCombatant.Id &&
          t.DurationTiming == "StartOfTurn"
      )
      .forEach(t => t.Decrement());

    nextCombatant.ReactionsSpent(0);

    this.TurnTimer.Reset();
  };

  public PreviousTurn = () => {
    const activeCombatant = this.ActiveCombatant();

    this.durationTags
      .filter(
        t =>
          t.HasDuration &&
          t.DurationCombatantId == activeCombatant.Id &&
          t.DurationTiming == "StartOfTurn"
      )
      .forEach(t => t.Increment());

    let previousIndex =
      this.encounter.Combatants().indexOf(activeCombatant) - 1;

    if (previousIndex < 0) {
      previousIndex = this.encounter.Combatants().length - 1;
      this.CombatTimer.DecrementCombatRounds();
    }

    const previousCombatant = this.encounter.Combatants()[previousIndex];
    this.ActiveCombatant(previousCombatant);

    activeCombatant.CombatTimer.DecrementCombatRounds();
    activeCombatant.CombatTimer.Pause();
    previousCombatant.CombatTimer.Start();

    this.durationTags
      .filter(
        t =>
          t.HasDuration &&
          t.DurationCombatantId == previousCombatant.Id &&
          t.DurationTiming == "EndOfTurn"
      )
      .forEach(t => t.Increment());

    this.TurnTimer.Reset();
  };

  public AddDurationTag = (tag: Tag) => {
    this.durationTags.push(tag);
  };

  private rerollInitiativeWithoutPrompt = () => {
    const combatants = this.encounter.Combatants();
    combatants.forEach(c => c.Initiative(c.GetInitiativeRoll()));
    this.encounter.SortByInitiative(false);
  };
}


================================================
FILE: client/Encounter/LegacyEncounter.test.ts
================================================
import {
  UpdateLegacyEncounterState,
  UpdateLegacySavedEncounter
} from "./UpdateLegacySavedEncounter";

function makev0_1StatBlock() {
  return {
    Name: "v0.1 Creature",
    Type: "",
    HP: { Value: 1 },
    AC: { Value: 10 },
    Speed: ["Walk 30"],
    Abilities: { Str: 10, Dex: 10, Con: 10, Cha: 10, Int: 10, Wis: 10 },
    DamageVulnerabilities: [],
    DamageResistances: [],
    DamageImmunities: [],
    ConditionImmunities: [],
    Saves: [],
    Skills: [],
    Senses: [],
    Languages: [],
    Challenge: "",
    Traits: [],
    Actions: [],
    LegendaryActions: []
  };
}

describe("UpdateLegacySavedEncounter", () => {
  test("Loads a v0.1 encounter", () => {
    const v1Encounter = {
      Name: "V0.1 Encounter",
      ActiveCreatureIndex: 0,
      Creatures: [
        {
          Statblock: makev0_1StatBlock(),
          CurrentHP: 1,
          TemporaryHP: 0,
          Initiative: 10,
          Alias: "",
          Tags: ["string tag"]
        }
      ]
    };

    const updatedEncounter = UpdateLegacySavedEncounter(v1Encounter);
    expect(updatedEncounter.Id).toBe("V01_Encounter");
    expect(updatedEncounter.Name).toBe("V0.1 Encounter");
    expect(updatedEncounter.Path).toBe("");
    expect(updatedEncounter.Version).toBe("legacy");
    expect(updatedEncounter.Combatants).toHaveLength(1);

    const updatedCombatant = updatedEncounter.Combatants[0];

    expect(updatedCombatant.Id).toHaveLength(8);
    expect(updatedCombatant.CurrentHP).toBe(1);
    expect(updatedCombatant.RevealedAC).toBe(false);
    expect(updatedCombatant.Tags).toEqual([
      {
        Text: "string tag",
        DurationRemaining:
Download .txt
gitextract_ukmc1mmn/

├── .dockerignore
├── .eslintignore
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── main.yml
│       └── node.js.yml
├── .gitignore
├── .nvmrc
├── .prettierrc.json
├── .travis.yml
├── .vscode/
│   └── launch.json
├── CONTRIBUTING.md
├── Dockerfile
├── Gruntfile.js
├── PRIVACY.md
├── Procfile
├── README.md
├── _config.yml
├── about.md
├── babel.config.json
├── client/
│   ├── .baseDir.ts
│   ├── .eslintrc.json
│   ├── Account/
│   │   ├── Account.ts
│   │   ├── AccountClient.test.ts
│   │   └── AccountClient.ts
│   ├── App.tsx
│   ├── AutosavedEncounterTest.test.tsx
│   ├── CombatFooter/
│   │   └── CombatFooter.tsx
│   ├── Combatant/
│   │   ├── Combatant.test.ts
│   │   ├── Combatant.ts
│   │   ├── CombatantDetails.tsx
│   │   ├── CombatantViewModel.ts
│   │   ├── GetOrRollMaximumHP.test.ts
│   │   ├── GetOrRollMaximumHP.ts
│   │   ├── IndexLabeling.test.ts
│   │   ├── MultipleCombatantDetails.tsx
│   │   ├── Tag.ts
│   │   ├── ToPlayerViewCombatantState.ts
│   │   └── linkComponentToObservables.tsx
│   ├── Commands/
│   │   ├── BuildCombatantCommandList.ts
│   │   ├── BuildEncounterCommandList.ts
│   │   ├── CombatantCommander.test.ts
│   │   ├── CombatantCommander.tsx
│   │   ├── Command.test.ts
│   │   ├── Command.ts
│   │   ├── CommandButton.tsx
│   │   ├── DefaultKeybindings.ts
│   │   ├── EncounterCommander.test.ts
│   │   ├── EncounterCommander.ts
│   │   ├── GetLegacyKeyBinding.ts
│   │   ├── LibrariesCommander.ts
│   │   ├── PromptQueue.test.ts
│   │   ├── PromptQueue.ts
│   │   ├── ToggleFullscreen.ts
│   │   ├── Toolbar.test.tsx
│   │   └── Toolbar.tsx
│   ├── Components/
│   │   ├── Button.tsx
│   │   ├── ErrorBoundary.tsx
│   │   ├── Info.tsx
│   │   ├── LoadingIndicator.tsx
│   │   ├── Overlay.tsx
│   │   ├── StatBlock.test.tsx
│   │   ├── StatBlock.tsx
│   │   ├── StatBlockHeader.tsx
│   │   └── Tabs.tsx
│   ├── Encounter/
│   │   ├── AutoPopulatedNotes.ts
│   │   ├── AutoRerollInitiativeOption.test.ts
│   │   ├── Encounter.test.ts
│   │   ├── Encounter.ts
│   │   ├── EncounterFlow.ts
│   │   ├── LegacyEncounter.test.ts
│   │   └── UpdateLegacySavedEncounter.ts
│   ├── Environment.ts
│   ├── GetContextualCommandSuggestion.tsx
│   ├── Importers/
│   │   ├── DnDAppFilesImporter.ts
│   │   ├── Importer.ts
│   │   ├── Open5eImporter.ts
│   │   ├── SpellImporter.test.ts
│   │   ├── SpellImporter.ts
│   │   ├── StatBlockImporter.test.ts
│   │   └── StatBlockImporter.ts
│   ├── Index.ts
│   ├── InitiativeList/
│   │   ├── CombatantRow.tsx
│   │   ├── CommandContext.tsx
│   │   ├── InitiativeList.test.tsx
│   │   ├── InitiativeList.tsx
│   │   ├── InitiativeListHeader.tsx
│   │   ├── InitiativeListHost.tsx
│   │   ├── RestoreCombatants.tsx
│   │   └── Tags.tsx
│   ├── LauncherViewModel.ts
│   ├── Layout/
│   │   ├── BannerHost.tsx
│   │   ├── CenterColumn.tsx
│   │   ├── LeftColumn.tsx
│   │   ├── RightColumn.tsx
│   │   ├── SelectedCombatants.tsx
│   │   ├── ThreeColumnLayout.tsx
│   │   ├── ToolbarHost.tsx
│   │   ├── VerticalResizer.tsx
│   │   ├── centerColumnView.tsx
│   │   └── interfacePriorityClass.tsx
│   ├── Library/
│   │   ├── Components/
│   │   │   ├── BuildListingTree.test.tsx
│   │   │   ├── BuildListingTree.tsx
│   │   │   ├── Folder.tsx
│   │   │   ├── LibraryFilter.tsx
│   │   │   ├── ListingButton.tsx
│   │   │   ├── ListingRow.tsx
│   │   │   ├── PaneHeader.tsx
│   │   │   └── SpellDetails.tsx
│   │   ├── FilterCache.test.ts
│   │   ├── FilterCache.ts
│   │   ├── Libraries.ts
│   │   ├── Listing.ts
│   │   ├── Manager/
│   │   │   ├── ActiveLibrary.tsx
│   │   │   ├── DeletePrompt.tsx
│   │   │   ├── EditorView.tsx
│   │   │   ├── LibraryManager.tsx
│   │   │   ├── LibraryManagerRow.tsx
│   │   │   ├── LibraryManagerToolbar.tsx
│   │   │   ├── ListingSelectionContext.ts
│   │   │   ├── MovePrompt.tsx
│   │   │   ├── SelectedItemsManager.tsx
│   │   │   ├── SelectedItemsView.tsx
│   │   │   ├── SelectedItemsViewForActiveTab.tsx
│   │   │   └── useSelection.ts
│   │   ├── ReferencePane/
│   │   │   ├── EncounterLibraryReferencePane.tsx
│   │   │   ├── LibraryReferencePane.tsx
│   │   │   ├── LibraryReferencePanes.tsx
│   │   │   ├── PersistentCharacterLibraryReferencePane.tsx
│   │   │   ├── SpellLibraryReferencePane.tsx
│   │   │   └── StatBlockLibraryReferencePane.tsx
│   │   ├── StatBlockLibrary.test.tsx
│   │   └── useLibrary.ts
│   ├── MockAccountClient.tsx
│   ├── PersistentCharacter/
│   │   └── PersistentCharacter.test.tsx
│   ├── PlayerView/
│   │   ├── CSSFrom.ts
│   │   ├── PlayerView.test.tsx
│   │   ├── PlayerViewClient.ts
│   │   ├── PlayerViewCombatantState.test.tsx
│   │   ├── PlayerViewEncounterState.test.tsx
│   │   ├── ReactPlayerView.tsx
│   │   ├── TurnTimer.test.tsx
│   │   └── components/
│   │       ├── CombatFooter.tsx
│   │       ├── CombatStatsPopup.tsx
│   │       ├── CustomStyles.tsx
│   │       ├── DamageSuggestor.tsx
│   │       ├── PlayerView.tsx
│   │       ├── PlayerViewCombatant.tsx
│   │       ├── PlayerViewCombatantHeader.tsx
│   │       ├── PortraitModal.tsx
│   │       ├── SpentReactionIndicator.tsx
│   │       └── TagSuggestor.tsx
│   ├── Prompts/
│   │   ├── AcceptDamagePrompt.tsx
│   │   ├── AcceptTagPrompt.tsx
│   │   ├── ApplyDamagePrompt.tsx
│   │   ├── ApplyHealingPrompt.tsx
│   │   ├── ApplyTemporaryHPPrompt.tsx
│   │   ├── CombatStatsPrompt.tsx
│   │   ├── ConcentrationPrompt.tsx
│   │   ├── ConditionReferencePrompt.tsx
│   │   ├── EditAliasPrompt.tsx
│   │   ├── EditInitiativePrompt.tsx
│   │   ├── InitiativePrompt.tsx
│   │   ├── LinkInitiativePrompt.tsx
│   │   ├── MoveEncounterPrompt.tsx
│   │   ├── PendingPrompts.tsx
│   │   ├── PlayerViewPrompt.tsx
│   │   ├── PrivacyPolicyPrompt.tsx
│   │   ├── QuickAddPrompt.tsx
│   │   ├── QuickEditStatBlockPrompt.tsx
│   │   ├── RollDicePrompt.tsx
│   │   ├── SaveEncounterPrompt.tsx
│   │   ├── SpellPrompt.tsx
│   │   ├── StandardPromptLayout.tsx
│   │   ├── TagPrompt.tsx
│   │   └── UpdateNotesPrompt.tsx
│   ├── Reducers/
│   │   ├── Actions.ts
│   │   ├── CombatantActions.tsx
│   │   ├── CombatantsReducer.test.tsx
│   │   ├── CombatantsReducer.tsx
│   │   ├── EncounterActions.tsx
│   │   ├── EncounterReducer.test.tsx
│   │   ├── EncounterReducer.tsx
│   │   ├── GetCombatantsSorted.tsx
│   │   └── InitializeCombatantFromStatBlock.tsx
│   ├── Rules/
│   │   ├── Conditions.ts
│   │   ├── Dice.ts
│   │   ├── RollResult.ts
│   │   ├── RollResults.test.ts
│   │   ├── Rules.test.ts
│   │   └── Rules.ts
│   ├── Settings/
│   │   ├── Settings.test.ts
│   │   ├── Settings.ts
│   │   ├── SettingsContext.ts
│   │   ├── Tips.ts
│   │   └── components/
│   │       ├── About.tsx
│   │       ├── AccountSettings.tsx
│   │       ├── AccountSyncSettings.tsx
│   │       ├── ColorBlock.tsx
│   │       ├── CommandInfo.ts
│   │       ├── CommandsSettings.tsx
│   │       ├── ContentSettings.tsx
│   │       ├── DisplaysToggle.tsx
│   │       ├── Dropdown.tsx
│   │       ├── EpicInitiativeSettings.tsx
│   │       ├── FileUploadButton.tsx
│   │       ├── LocalDataSettings.tsx
│   │       ├── OptionsSettings.tsx
│   │       ├── SettingsPane.tsx
│   │       ├── StatBlockCustomFields.tsx
│   │       ├── StylesChooser.tsx
│   │       ├── TipCarousel.tsx
│   │       └── Toggle.tsx
│   ├── StatBlockEditor/
│   │   ├── ConvertStringsToNumbersWhereNeeded.tsx
│   │   ├── EnumToggle.tsx
│   │   ├── SavedEncounterEditor.tsx
│   │   ├── SpellEditor.tsx
│   │   ├── StatBlockEditor.test.tsx
│   │   ├── StatBlockEditor.tsx
│   │   └── components/
│   │       ├── AutoHideField.tsx
│   │       ├── AutocompleteTextInput.tsx
│   │       ├── IdentityFields.tsx
│   │       ├── KeywordField.tsx
│   │       ├── NameAndModifierField.tsx
│   │       ├── PowerField.tsx
│   │       ├── SortableList.tsx
│   │       ├── StatBlockEditorFields.tsx
│   │       ├── TextField.tsx
│   │       ├── UseDragDrop.tsx
│   │       └── useFocus.ts
│   ├── TextEnricher/
│   │   ├── Counter.tsx
│   │   ├── TextEnricher.test.tsx
│   │   └── TextEnricher.tsx
│   ├── TrackerViewModel.tsx
│   ├── Tutorial/
│   │   ├── NotifyTutorialOfAction.ts
│   │   ├── Tutorial.tsx
│   │   └── TutorialSteps.ts
│   ├── Utility/
│   │   ├── CustomBindingHandlers.ts
│   │   ├── GetAlphaSortableLevelString.ts
│   │   ├── LegacySynchronousLocalStore.ts
│   │   ├── Metrics.ts
│   │   ├── RemovableArrayValue.ts
│   │   ├── Store.test.ts
│   │   ├── Store.ts
│   │   ├── TextAssets.ts
│   │   ├── TransferLocalStorage.ts
│   │   ├── useAsyncListing.tsx
│   │   ├── useRequest.ts
│   │   └── useStoreBackedState.ts
│   ├── Widgets/
│   │   ├── CombatTimer.ts
│   │   ├── DifficultyCalculator.test.ts
│   │   ├── DifficultyCalculator.ts
│   │   ├── EventLog.ts
│   │   └── GetTimerReadout.ts
│   ├── jest.config.js
│   ├── test/
│   │   ├── InitializeTestSettings.ts
│   │   ├── adapterSetup.ts
│   │   ├── buildEncounter.ts
│   │   └── mocksSetup.ts
│   ├── tsconfig.eslint.json
│   └── tsconfig.json
├── common/
│   ├── ClientEnvironment.ts
│   ├── CombatStats.ts
│   ├── CombatantState.ts
│   ├── CommandSetting.ts
│   ├── DurationTiming.ts
│   ├── EncounterState.ts
│   ├── Listable.ts
│   ├── PatreonPost.ts
│   ├── PersistentCharacter.ts
│   ├── PlayerViewCombatantState.ts
│   ├── PlayerViewSettings.ts
│   ├── PlayerViewState.ts
│   ├── SavedEncounter.ts
│   ├── Settings.ts
│   ├── Spell.ts
│   ├── StatBlock.ts
│   ├── Toolbox.ts
│   ├── ValidateEncounterId.ts
│   └── jest.config.js
├── html/
│   ├── landing.html
│   ├── playerview.html
│   ├── tracker.html
│   └── transferlocalstorage.html
├── lesscss/
│   ├── base/
│   │   ├── colors.less
│   │   ├── responsive.less
│   │   └── typography.less
│   ├── components/
│   │   ├── buttons.less
│   │   ├── cards.less
│   │   ├── combat-footer.less
│   │   ├── combatants.less
│   │   ├── libraries.less
│   │   ├── library-manager.less
│   │   ├── listing.less
│   │   ├── overlay.less
│   │   ├── prompts.less
│   │   ├── settings.less
│   │   ├── spell-editor.less
│   │   ├── spell.less
│   │   ├── statblock-editor.less
│   │   ├── statblock.less
│   │   ├── styles-chooser.less
│   │   ├── tabs.less
│   │   ├── toolbar.less
│   │   └── tutorial.less
│   ├── improved-initiative.less
│   ├── layout/
│   │   ├── base.less
│   │   └── forms.less
│   ├── pages/
│   │   ├── landing.less
│   │   ├── player-view.less
│   │   └── tracker.less
│   └── utilities/
│       ├── animations.less
│       └── helpers.less
├── license
├── ogl_creatures.json
├── ogl_spells.json
├── package.json
├── public/
│   ├── .well-known/
│   │   └── assetlinks.json
│   ├── BingSiteAuth.xml
│   ├── manifest.json
│   ├── robots.txt
│   └── sample_players.json
├── server/
│   ├── .baseDir.ts
│   ├── InMemoryPlayerViewManager.ts
│   ├── RedisPlayerViewManager.ts
│   ├── api_response_declined_pledge.json
│   ├── api_response_epic_account.json
│   ├── api_response_no_pledge.json
│   ├── configureAffiliateRoutes.ts
│   ├── configureBasicRulesContent.ts
│   ├── configureImportRoutes.ts
│   ├── configureOpen5eContent.ts
│   ├── dbconnection.test.ts
│   ├── dbconnection.ts
│   ├── getDbConnectionString.ts
│   ├── jest.config.js
│   ├── library.ts
│   ├── metrics.ts
│   ├── patreon.ts
│   ├── playerviewmanager.test.ts
│   ├── playerviewmanager.ts
│   ├── routes.ts
│   ├── server.ts
│   ├── session.ts
│   ├── sockets.ts
│   ├── storageroutes.ts
│   ├── tsconfig.json
│   └── user.ts
├── test-post.html
├── test_cases.txt
├── thanks.ts
├── web.config
├── webpack.config.base.js
├── webpack.config.dev.js
└── webpack.config.prod.js
Download .txt
SYMBOL INDEX (772 symbols across 230 files)

FILE: client/Account/Account.ts
  type Account (line 4) | interface Account {

FILE: client/Account/AccountClient.test.ts
  function fakeListing (line 6) | async function fakeListing(

FILE: client/Account/AccountClient.ts
  constant DEFAULT_BATCH_SIZE (line 17) | const DEFAULT_BATCH_SIZE = 10;
  constant ENCOUNTER_BATCH_SIZE (line 18) | const ENCOUNTER_BATCH_SIZE = 1;
  class AccountClient (line 20) | class AccountClient {
    method GetAccount (line 21) | public GetAccount(callBack: (user: Account | null) => void): true | vo...
    method DeleteAccount (line 36) | public async DeleteAccount(): Promise<any> {
    method GetFullAccount (line 44) | public async GetFullAccount(): Promise<any> {
    method SaveAllUnsyncedItems (line 53) | public async SaveAllUnsyncedItems(
    method SaveSettings (line 99) | public SaveSettings(settings: Settings): Promise<Settings> {
    method SaveStatBlock (line 103) | public SaveStatBlock(statBlock: StatBlock): Promise<StatBlock> {
    method DeleteStatBlock (line 107) | public DeleteStatBlock(statBlockId: string): Promise<any> {
    method SavePlayerCharacter (line 111) | public SavePlayerCharacter(playerCharacter: StatBlock): Promise<StatBl...
    method DeletePlayerCharacter (line 115) | public DeletePlayerCharacter(statBlockId: string): Promise<any> {
    method SavePersistentCharacter (line 119) | public SavePersistentCharacter(
    method DeletePersistentCharacter (line 128) | public DeletePersistentCharacter(
    method SaveEncounter (line 134) | public SaveEncounter(encounter: SavedEncounter): Promise<SavedEncounte...
    method DeleteEncounter (line 138) | public DeleteEncounter(encounterId: string): Promise<any> {
    method SaveSpell (line 142) | public SaveSpell(spell: Spell): Promise<Spell> {
    method DeleteSpell (line 146) | public DeleteSpell(spellId: string): Promise<any> {
    method SanitizeForId (line 150) | private static SanitizeForId(str: string) {
    method MakeId (line 154) | public static MakeId(name: string, path?: string): string {
  function emptyPromise (line 163) | function emptyPromise() {
  function saveEntity (line 167) | function saveEntity<T>(entity: T, entityType: string): Promise<T | null> {
  function getUnsyncedItemsFromListings (line 192) | async function getUnsyncedItemsFromListings(
  function getUnsyncedItems (line 199) | async function getUnsyncedItems(items: Listing<Listable>[]) {
  function sanitizeItems (line 231) | function sanitizeItems(items: Listable[]) {
  function saveEntitySet (line 247) | async function saveEntitySet<Listable>(
  function deleteEntity (line 270) | function deleteEntity(entityId: string, entityType: string) {

FILE: client/App.tsx
  function App (line 28) | function App(props: { tracker: TrackerViewModel }): JSX.Element {

FILE: client/AutosavedEncounterTest.test.tsx
  function CombatantStateWithName (line 15) | function CombatantStateWithName(name: string): CombatantState {

FILE: client/CombatFooter/CombatFooter.tsx
  type CombatFooterProps (line 9) | type CombatFooterProps = {
  function CombatFooter (line 14) | function CombatFooter(props: CombatFooterProps) {
  function getDifficultyString (line 54) | function getDifficultyString(difficulty: EncounterDifficulty) {
  function FullEventLog (line 62) | function FullEventLog(props: { eventsTail: string[] }) {
  function TurnTimerReadout (line 83) | function TurnTimerReadout(props: {
  function EventLogItem (line 91) | function EventLogItem(props: { eventMarkdown: string }) {

FILE: client/Combatant/Combatant.ts
  class Combatant (line 14) | class Combatant {
    method constructor (line 15) | constructor(
    method processStatBlock (line 72) | private processStatBlock(oldStatBlockName?: string) {
    method processCombatantState (line 83) | private processCombatantState(savedCombatant: CombatantState) {
    method AttachToPersistentCharacterLibrary (line 102) | public AttachToPersistentCharacterLibrary(
    method UpdateIndexLabel (line 123) | public UpdateIndexLabel(oldName?: string) {
    method ApplyDamage (line 196) | public ApplyDamage(damage: number) {
    method ApplyHealing (line 217) | public ApplyHealing(healing: number) {
    method ApplyTemporaryHP (line 228) | public ApplyTemporaryHP(tempHP: number) {
    method findLowestInitiativeGroupByName (line 300) | private findLowestInitiativeGroupByName(): Combatant {
    method findLowestInitiativeGroupBySide (line 308) | private findLowestInitiativeGroupBySide(): Combatant {

FILE: client/Combatant/CombatantDetails.tsx
  type CombatantDetailsProps (line 12) | interface CombatantDetailsProps {
  function CombatantDetails (line 18) | function CombatantDetails(props: CombatantDetailsProps): JSX.Element {
  function TagDetails (line 94) | function TagDetails(props: { tag: Tag }) {
  function renderHPBarStyle (line 110) | function renderHPBarStyle(currentHPPercentage) {

FILE: client/Combatant/CombatantViewModel.ts
  class CombatantViewModel (line 17) | class CombatantViewModel {
    method constructor (line 22) | constructor(
    method ApplyDamage (line 46) | public ApplyDamage(inputDamage: string) {
    method ApplyTemporaryHP (line 70) | public ApplyTemporaryHP(newTemporaryHP: number) {
    method ApplyInitiative (line 78) | public ApplyInitiative(initiative: number) {
    method EditInitiative (line 83) | public EditInitiative() {
    method SetAlias (line 103) | public SetAlias() {
    method ToggleSpentReaction (line 121) | public ToggleSpentReaction(): void {
    method ToggleHidden (line 129) | public ToggleHidden() {
    method ToggleRevealedAC (line 145) | public ToggleRevealedAC() {

FILE: client/Combatant/GetOrRollMaximumHP.ts
  type VariantMaximumHP (line 5) | enum VariantMaximumHP {
  function GetOrRollMaximumHP (line 11) | function GetOrRollMaximumHP(

FILE: client/Combatant/IndexLabeling.test.ts
  function buildEncounterState (line 72) | function buildEncounterState() {

FILE: client/Combatant/MultipleCombatantDetails.tsx
  type MultipleCombatantDetailsProps (line 6) | interface MultipleCombatantDetailsProps {
  class MultipleCombatantDetails (line 10) | class MultipleCombatantDetails extends React.Component<MultipleCombatant...
    method render (line 11) | public render() {

FILE: client/Combatant/Tag.ts
  type Tag (line 10) | interface Tag {
    method constructor (line 23) | constructor(
  class Tag (line 22) | class Tag implements Tag {
    method constructor (line 23) | constructor(

FILE: client/Combatant/ToPlayerViewCombatantState.ts
  function ToPlayerViewCombatantState (line 6) | function ToPlayerViewCombatantState(
  function GetHPDisplay (line 35) | function GetHPDisplay(combatant: Combatant): string {
  function GetHPColor (line 65) | function GetHPColor(combatant: Combatant) {

FILE: client/Combatant/linkComponentToObservables.tsx
  function linkComponentToObservables (line 5) | function linkComponentToObservables(component: React.Component) {
  function useSubscription (line 21) | function useSubscription<T>(observable: KnockoutObservable<T>): T {

FILE: client/Commands/CombatantCommander.tsx
  type PendingLinkInitiative (line 30) | interface PendingLinkInitiative {
  class CombatantCommander (line 35) | class CombatantCommander {
    method constructor (line 39) | constructor(private tracker: TrackerViewModel) {
    method applyDamageForCombatants (line 211) | private applyDamageForCombatants(combatantViewModels: CombatantViewMod...

FILE: client/Commands/Command.ts
  class Command (line 10) | class Command {
    method constructor (line 21) | constructor(props: {

FILE: client/Commands/CommandButton.tsx
  function CommandButton (line 7) | function CommandButton(props: { command: Command; showLabel: boolean }) {
  function commandButtonTooltip (line 33) | function commandButtonTooltip(c: Command) {

FILE: client/Commands/EncounterCommander.test.ts
  function buildSavedEncounterWithPersistentCharacter (line 138) | function buildSavedEncounterWithPersistentCharacter() {
  function buildEncounterState (line 181) | function buildEncounterState() {

FILE: client/Commands/EncounterCommander.ts
  class EncounterCommander (line 19) | class EncounterCommander {
    method constructor (line 20) | constructor(private tracker: TrackerViewModel) {}

FILE: client/Commands/GetLegacyKeyBinding.ts
  function GetLegacyKeyBinding (line 33) | function GetLegacyKeyBinding(id: string) {

FILE: client/Commands/LibrariesCommander.ts
  class LibrariesCommander (line 28) | class LibrariesCommander {
    method constructor (line 31) | constructor(
    method EditPersistentCharacterStatBlock (line 195) | public EditPersistentCharacterStatBlock(

FILE: client/Commands/PromptQueue.test.ts
  function MockPrompt (line 4) | function MockPrompt(): PromptProps<{}> {

FILE: client/Commands/PromptQueue.ts
  class PromptQueue (line 6) | class PromptQueue {
    method constructor (line 7) | constructor() {}

FILE: client/Commands/ToggleFullscreen.ts
  function ToggleFullscreen (line 1) | function ToggleFullscreen() {
  function FullscreenSupported (line 12) | function FullscreenSupported() {

FILE: client/Commands/Toolbar.tsx
  type ToolbarProps (line 5) | interface ToolbarProps {
  function Toolbar (line 12) | function Toolbar(props: ToolbarProps) {

FILE: client/Components/Button.tsx
  type ButtonProps (line 5) | interface ButtonProps {
  function Button (line 19) | function Button(props: ButtonProps): JSX.Element {
  function SubmitButton (line 62) | function SubmitButton(

FILE: client/Components/ErrorBoundary.tsx
  type ErrorBoundaryProps (line 3) | interface ErrorBoundaryProps {
  type ErrorBoundaryState (line 8) | interface ErrorBoundaryState {
  class ErrorBoundary (line 14) | class ErrorBoundary extends React.Component<
    method constructor (line 24) | constructor(props: ErrorBoundaryProps) {
    method componentDidCatch (line 28) | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    method render (line 36) | render() {

FILE: client/Components/Info.tsx
  function Info (line 4) | function Info(props: {

FILE: client/Components/LoadingIndicator.tsx
  function LoadingIndicator (line 3) | function LoadingIndicator() {

FILE: client/Components/Overlay.tsx
  type OverlayProps (line 4) | interface OverlayProps {
  type OverlayState (line 11) | interface OverlayState {
  class Overlay (line 15) | class Overlay extends React.Component<OverlayProps, OverlayState> {
    method constructor (line 16) | constructor(props: OverlayProps) {
    method render (line 23) | public render() {
    method componentDidMount (line 46) | public componentDidMount() {
    method componentDidUpdate (line 50) | public componentDidUpdate() {
    method updateHeight (line 54) | private updateHeight() {

FILE: client/Components/StatBlock.tsx
  type StatBlockProps (line 14) | interface StatBlockProps {
  function StatBlockComponent (line 21) | function StatBlockComponent(props: StatBlockProps) {
  function StatBlockComponentNoError (line 39) | function StatBlockComponentNoError(props: StatBlockProps) {

FILE: client/Components/StatBlockHeader.tsx
  type StatBlockHeaderProps (line 3) | interface StatBlockHeaderProps {
  type StatBlockHeaderState (line 11) | interface StatBlockHeaderState {
  class StatBlockHeader (line 15) | class StatBlockHeader extends React.Component<
    method constructor (line 19) | constructor(props) {
    method render (line 24) | public render() {

FILE: client/Components/Tabs.tsx
  function Tabs (line 3) | function Tabs<TKey extends string>(props: {

FILE: client/Encounter/AutoPopulatedNotes.ts
  function AutoPopulatedNotes (line 3) | function AutoPopulatedNotes(statBlock: StatBlock): string {
  function GetDailyCounters (line 54) | function GetDailyCounters(statBlockEntries: NameAndContent[]) {
  function GetRechargeCounters (line 61) | function GetRechargeCounters(statBlockEntries: NameAndContent[]) {

FILE: client/Encounter/Encounter.ts
  class Encounter (line 37) | class Encounter {
    method constructor (line 42) | constructor(
    method getGroupBonusForCombatant (line 80) | private getGroupBonusForCombatant(combatant: Combatant) {
    method getCombatantSortIteratees (line 92) | private getCombatantSortIteratees(
    method AddCombatantFromPersistentCharacter (line 223) | public AddCombatantFromPersistentCharacter(
    method UpdatePersistentCharacterStatBlock (line 307) | public UpdatePersistentCharacterStatBlock(
    method MoveCombatant (line 321) | public MoveCombatant(combatant: Combatant, index: number) {
    method CleanInitiativeGroups (line 349) | public CleanInitiativeGroups() {
    method getPlayerViewActiveCombatantId (line 478) | private getPlayerViewActiveCombatantId() {
    method getCombatantsForPlayerView (line 494) | private getCombatantsForPlayerView(activeCombatantId: string | null) {
    method DisplayPlayerViewCombatStats (line 536) | public DisplayPlayerViewCombatStats(stats: CombatStats) {

FILE: client/Encounter/EncounterFlow.ts
  class EncounterFlow (line 11) | class EncounterFlow {
    method constructor (line 22) | constructor(private encounter: Encounter) {}

FILE: client/Encounter/LegacyEncounter.test.ts
  function makev0_1StatBlock (line 6) | function makev0_1StatBlock() {

FILE: client/Encounter/UpdateLegacySavedEncounter.ts
  function updateLegacySavedCombatant (line 7) | function updateLegacySavedCombatant(savedCombatant: any) {
  function getActiveCombatantId (line 36) | function getActiveCombatantId(savedEncounter: any): string | null {
  function UpdateLegacySavedEncounter (line 49) | function UpdateLegacySavedEncounter(
  function UpdateLegacyEncounterState (line 70) | function UpdateLegacyEncounterState(

FILE: client/Environment.ts
  function LoadEnvironment (line 17) | function LoadEnvironment() {

FILE: client/GetContextualCommandSuggestion.tsx
  function GetContextualCommandSuggestion (line 1) | function GetContextualCommandSuggestion(

FILE: client/Importers/DnDAppFilesImporter.ts
  class DnDAppFilesImporter (line 26) | class DnDAppFilesImporter {

FILE: client/Importers/Importer.ts
  class Importer (line 3) | class Importer {
    method constructor (line 4) | constructor(protected domElement: Element) {}
    method getString (line 6) | public getString(selector) {
    method getInt (line 10) | public getInt(selector) {
    method getValueAndNotes (line 18) | public getValueAndNotes(selector: string) {
    method getCommaSeparatedStrings (line 30) | public getCommaSeparatedStrings(selector: string) {
    method getCommaSeparatedModifiers (line 38) | public getCommaSeparatedModifiers(selector: string) {
    method getPowers (line 53) | public getPowers(selector: string) {

FILE: client/Importers/Open5eImporter.ts
  function ImportOpen5eStatBlock (line 11) | function ImportOpen5eStatBlock(
  function ImportOpen5eSpell (line 63) | function ImportOpen5eSpell(open5eSpell: Record<string, any>): Spell {
  function parenthesizeOrEmpty (line 82) | function parenthesizeOrEmpty(input: string | undefined) {
  function commaSeparatedStrings (line 89) | function commaSeparatedStrings(input: string | undefined) {
  function getSaves (line 96) | function getSaves(sb: any): NameAndModifier[] {
  function nameAndDescArrays (line 143) | function nameAndDescArrays(entries: any): NameAndContent[] {
  function getNameAndContent (line 150) | function getNameAndContent(data: {
  function getType (line 160) | function getType(data: any): string {
  function ImportOpen5eV2StatBlock (line 179) | function ImportOpen5eV2StatBlock(
  function getTypeV2 (line 253) | function getTypeV2(data: any): string {
  function getSpeedV2 (line 268) | function getSpeedV2(data: any): string[] {
  function getSensesV2 (line 280) | function getSensesV2(data: any): string[] {
  function nameAndDescArraysV2 (line 297) | function nameAndDescArraysV2(entries: any): NameAndContent[] {
  function getNameAndContentV2 (line 304) | function getNameAndContentV2(data: {

FILE: client/Importers/SpellImporter.ts
  class SpellImporter (line 7) | class SpellImporter extends Importer {
    method getSource (line 19) | public getSource() {
    method getDescription (line 33) | public getDescription() {

FILE: client/Importers/StatBlockImporter.ts
  class StatBlockImporter (line 6) | class StatBlockImporter extends Importer {
    method getType (line 12) | public getType(): string {
    method getSource (line 34) | public getSource(): string {
    method getAbilities (line 51) | public getAbilities() {
    method GetStatBlock (line 62) | public GetStatBlock() {

FILE: client/InitiativeList/CombatantRow.tsx
  type CombatantRowProps (line 14) | type CombatantRowProps = {
  type CombatantDragData (line 22) | type CombatantDragData = {
  function CombatantRow (line 27) | function CombatantRow(props: CombatantRowProps) {
  function ReactionIndicator (line 216) | function ReactionIndicator(props: { combatantState: CombatantState }) {
  function CombatantColorPicker (line 244) | function CombatantColorPicker(props: { combatantState: CombatantState }) {
  function Commands (line 281) | function Commands() {
  function CommandButton (line 293) | function CommandButton(props: { command: Command }) {
  function getClassNames (line 313) | function getClassNames(props: CombatantRowProps) {
  function getDisplayName (line 324) | function getDisplayName(props: CombatantRowProps) {
  function getHPStyle (line 334) | function getHPStyle(props: CombatantRowProps) {
  function renderHPText (line 343) | function renderHPText(props: CombatantRowProps) {
  function renderHPBarStyle (line 351) | function renderHPBarStyle(props: CombatantRowProps) {

FILE: client/InitiativeList/InitiativeList.tsx
  function InitiativeList (line 9) | function InitiativeList(props: {

FILE: client/InitiativeList/InitiativeListHeader.tsx
  function InitiativeListHeader (line 5) | function InitiativeListHeader(props: { encounterActive: boolean }) {

FILE: client/InitiativeList/InitiativeListHost.tsx
  function InitiativeListHost (line 9) | function InitiativeListHost(props: { tracker: TrackerViewModel }) {

FILE: client/InitiativeList/RestoreCombatants.tsx
  function getCombatantsRemovedMessage (line 54) | function getCombatantsRemovedMessage(

FILE: client/InitiativeList/Tags.tsx
  function Tags (line 8) | function Tags(props: { tags: TagState[]; combatantId: string }) {
  function Tag (line 22) | function Tag(props: { combatantId: string; tag: TagState }) {

FILE: client/LauncherViewModel.ts
  class LauncherViewModel (line 9) | class LauncherViewModel {
    method constructor (line 10) | constructor() {
    method cleanAffiliateUrl (line 22) | private cleanAffiliateUrl() {

FILE: client/Layout/BannerHost.tsx
  function BannerHost (line 6) | function BannerHost(): JSX.Element {

FILE: client/Layout/CenterColumn.tsx
  function CenterColumn (line 13) | function CenterColumn(props: {

FILE: client/Layout/LeftColumn.tsx
  function LeftColumn (line 10) | function LeftColumn(props: {
  function ActiveCombatant (line 47) | function ActiveCombatant(props: {

FILE: client/Layout/RightColumn.tsx
  function RightColumn (line 6) | function RightColumn(props: {

FILE: client/Layout/SelectedCombatants.tsx
  function SelectedCombatants (line 8) | function SelectedCombatants(props: {

FILE: client/Layout/ThreeColumnLayout.tsx
  function ThreeColumnLayout (line 12) | function ThreeColumnLayout(props: { tracker: TrackerViewModel }) {

FILE: client/Layout/ToolbarHost.tsx
  function ToolbarHost (line 7) | function ToolbarHost(props: { tracker: TrackerViewModel }) {

FILE: client/Layout/VerticalResizer.tsx
  function VerticalResizer (line 4) | function VerticalResizer(props: {
  function useVerticalResizerDrop (line 33) | function useVerticalResizerDrop() {

FILE: client/Layout/centerColumnView.tsx
  function centerColumnView (line 4) | function centerColumnView(

FILE: client/Layout/interfacePriorityClass.tsx
  function interfacePriorityClass (line 1) | function interfacePriorityClass(

FILE: client/Library/Components/BuildListingTree.tsx
  type ListingGroup (line 7) | type ListingGroup = {
  type FolderModel (line 16) | type FolderModel = {
  function BuildListingTree (line 22) | function BuildListingTree<T extends Listable>(
  function buildFolderComponents (line 60) | function buildFolderComponents<T extends Listable>(
  function ensureFolder (line 82) | function ensureFolder(

FILE: client/Library/Components/Folder.tsx
  function Folder (line 4) | function Folder(props: { name: string; children: React.ReactNode }) {

FILE: client/Library/Components/LibraryFilter.tsx
  type LibraryFilterProps (line 5) | interface LibraryFilterProps {
  function LibraryFilter (line 9) | function LibraryFilter(props: LibraryFilterProps): JSX.Element {

FILE: client/Library/Components/ListingButton.tsx
  type Props (line 3) | interface Props {
  function ListingButton (line 14) | function ListingButton(props: Props) {

FILE: client/Library/Components/ListingRow.tsx
  type ExtraButton (line 9) | interface ExtraButton<T extends Listable> {
  type ListingProps (line 16) | interface ListingProps<T extends Listable> {
  type ListingState (line 33) | interface ListingState {
  class ListingRow (line 37) | class ListingRow<T extends Listable> extends React.Component<
    method constructor (line 65) | constructor(props) {
    method render (line 70) | public render(): JSX.Element {

FILE: client/Library/Components/PaneHeader.tsx
  function PaneHeader (line 3) | function PaneHeader(props: {

FILE: client/Library/Components/SpellDetails.tsx
  function SpellDetails (line 19) | function SpellDetails(props: { Spell: Spell; isLoading?: boolean }) {
  function getSpellType (line 79) | function getSpellType(spell: Spell) {

FILE: client/Library/FilterCache.test.ts
  function makeStatBlockListing (line 5) | function makeStatBlockListing(partialStatblock: Partial<StatBlock>) {

FILE: client/Library/FilterCache.ts
  function DedupeByRankAndFilterListings (line 5) | function DedupeByRankAndFilterListings<T extends Listing<Listable>>(
  class FilterCache (line 65) | class FilterCache<T extends Listing<Listable>> {
    method constructor (line 68) | constructor(initialItems: T[]) {
    method UpdateIfItemsChanged (line 74) | public UpdateIfItemsChanged(newItems: T[]) {
    method itemsHaveUpdatedMeta (line 84) | private itemsHaveUpdatedMeta(newItems: T[]) {
    method initializeItems (line 112) | private initializeItems(items: T[]) {

FILE: client/Library/Libraries.ts
  type UpdatePersistentCharacter (line 21) | type UpdatePersistentCharacter = (
  type LibraryType (line 40) | type LibraryType = keyof typeof LibraryFriendlyNames;
  function GetDefaultForLibrary (line 42) | function GetDefaultForLibrary(libraryType: LibraryType): Listable {
  type Libraries (line 59) | interface Libraries {
  function dummyLibrary (line 66) | function dummyLibrary<T extends Listable>(): Library<T> {
  function useLibraries (line 84) | function useLibraries(
  function preloadStatBlocks (line 161) | async function preloadStatBlocks(
  function preloadSpells (line 186) | async function preloadSpells(Spells: Library<Spell>, settings: Settings) {
  function getAccountOrSampleCharacters (line 206) | function getAccountOrSampleCharacters(

FILE: client/Library/Listing.ts
  type ListingOrigin (line 10) | type ListingOrigin =
  class Listing (line 18) | class Listing<T extends Listable> {
    method constructor (line 19) | constructor(
    method GetWithTemplate (line 34) | public GetWithTemplate(template: T): Promise<T> {
    method GetAsyncWithUpdatedId (line 42) | public GetAsyncWithUpdatedId(callback: (item: any) => any): any {

FILE: client/Library/Manager/ActiveLibrary.tsx
  function ActiveLibrary (line 5) | function ActiveLibrary(

FILE: client/Library/Manager/DeletePrompt.tsx
  function DeletePrompt (line 8) | function DeletePrompt(props: {

FILE: client/Library/Manager/EditorView.tsx
  type EditorViewProps (line 15) | type EditorViewProps = LibraryManagerProps & {
  function EditorView (line 20) | function EditorView(props: EditorViewProps) {
  function RenderStatBlockEditor (line 52) | function RenderStatBlockEditor(
  function RenderPersistentCharacterEditor (line 89) | function RenderPersistentCharacterEditor(
  function RenderSpellEditor (line 120) | function RenderSpellEditor(
  function RenderSavedEncounterEditor (line 144) | function RenderSavedEncounterEditor(

FILE: client/Library/Manager/LibraryManager.tsx
  type LibraryManagerProps (line 25) | type LibraryManagerProps = {
  function LibraryManager (line 32) | function LibraryManager(props: LibraryManagerProps): JSX.Element {
  function LibraryManagerListings (line 120) | function LibraryManagerListings(props: {
  function handleListingsScroll (line 159) | function handleListingsScroll(

FILE: client/Library/Manager/LibraryManagerRow.tsx
  function LibraryManagerRow (line 9) | function LibraryManagerRow(props: {
  function SourceIndicator (line 61) | function SourceIndicator(props: { origin: ListingOrigin; source?: string...

FILE: client/Library/Manager/LibraryManagerToolbar.tsx
  function LibraryManagerToolbar (line 4) | function LibraryManagerToolbar(props: { closeManager: () => void }) {

FILE: client/Library/Manager/MovePrompt.tsx
  function MovePrompt (line 8) | function MovePrompt(props: {

FILE: client/Library/Manager/SelectedItemsManager.tsx
  type PromptTypeAndTargets (line 23) | type PromptTypeAndTargets = ["move" | "delete", Listing<Listable>[]] | n...
  function SelectedItemsManager (line 25) | function SelectedItemsManager(props: {
  function exportSelectedItems (line 101) | async function exportSelectedItems(
  function ActivePrompt (line 123) | function ActivePrompt(props: {

FILE: client/Library/Manager/SelectedItemsView.tsx
  function SelectedItemsView (line 6) | function SelectedItemsView<T extends Listable>(props: {
  function LibraryManagerInfo (line 46) | function LibraryManagerInfo() {

FILE: client/Library/Manager/SelectedItemsViewForActiveTab.tsx
  function SelectedItemsViewForActiveTab (line 15) | function SelectedItemsViewForActiveTab({

FILE: client/Library/Manager/useSelection.ts
  type Selection (line 5) | type Selection<T> = {
  function useSelection (line 13) | function useSelection<T>(): Selection<T> {

FILE: client/Library/ReferencePane/EncounterLibraryReferencePane.tsx
  type EncounterLibraryReferencePaneProps (line 11) | type EncounterLibraryReferencePaneProps = {
  type EncounterListing (line 16) | type EncounterListing = Listing<SavedEncounter>;
  class EncounterLibraryReferencePane (line 18) | class EncounterLibraryReferencePane extends React.Component<EncounterLib...
    method constructor (line 19) | constructor(props: EncounterLibraryReferencePaneProps) {
    method render (line 24) | public render(): JSX.Element {

FILE: client/Library/ReferencePane/LibraryReferencePane.tsx
  type LibraryReferencePaneProps (line 12) | interface LibraryReferencePaneProps<T extends Listable> {
  type State (line 33) | interface State<T extends Listable> {
  class LibraryReferencePane (line 45) | class LibraryReferencePane<T extends Listable> extends React.Component<
    method constructor (line 51) | constructor(props: LibraryReferencePaneProps<T>) {
    method render (line 68) | public render(): JSX.Element {
    method getRecentItems (line 198) | private getRecentItems(

FILE: client/Library/ReferencePane/LibraryReferencePanes.tsx
  type LibraryReferencePanesProps (line 14) | interface LibraryReferencePanesProps {
  type State (line 19) | interface State {
  class LibraryReferencePanes (line 23) | class LibraryReferencePanes extends React.Component<
    method constructor (line 27) | constructor(props) {
    method render (line 42) | public render() {
  function LibraryHeader (line 104) | function LibraryHeader(props: { selectedLibrary: LibraryType }) {

FILE: client/Library/ReferencePane/PersistentCharacterLibraryReferencePane.tsx
  type PersistentCharacterLibraryReferencePaneProps (line 14) | type PersistentCharacterLibraryReferencePaneProps = {
  class PersistentCharacterLibraryReferencePane (line 19) | class PersistentCharacterLibraryReferencePane extends React.Component<Pe...
    method constructor (line 20) | constructor(props: PersistentCharacterLibraryReferencePaneProps) {
    method render (line 74) | public render(): JSX.Element {

FILE: client/Library/ReferencePane/SpellLibraryReferencePane.tsx
  type SpellLibraryReferencePaneProps (line 14) | type SpellLibraryReferencePaneProps = {
  type SpellListing (line 19) | type SpellListing = Listing<Spell>;
  class SpellLibraryReferencePane (line 21) | class SpellLibraryReferencePane extends React.Component<SpellLibraryRefe...
    method constructor (line 22) | constructor(props: SpellLibraryReferencePaneProps) {
    method render (line 27) | public render(): JSX.Element {
  function LevelOrCantrip (line 89) | function LevelOrCantrip(levelString: string) {

FILE: client/Library/ReferencePane/StatBlockLibraryReferencePane.tsx
  type StatBlockLibraryReferencePaneProps (line 16) | type StatBlockLibraryReferencePaneProps = {
  type StatBlockListing (line 21) | type StatBlockListing = Listing<StatBlock>;
  type State (line 23) | interface State {
  class StatBlockLibraryReferencePane (line 32) | class StatBlockLibraryReferencePane extends React.Component<
    method constructor (line 36) | constructor(props: StatBlockLibraryReferencePaneProps) {
    method render (line 50) | public render(): JSX.Element {

FILE: client/Library/StatBlockLibrary.test.tsx
  function LibraryTest (line 10) | function LibraryTest(props: {

FILE: client/Library/useLibrary.ts
  type Library (line 10) | interface Library<T extends Listable> {
  function useLibrary (line 29) | function useLibrary<T extends Listable>(
  function useSaveListing (line 218) | function useSaveListing<T extends Listable>(

FILE: client/MockAccountClient.tsx
  function MockAccountClient (line 4) | function MockAccountClient(): AccountClient {

FILE: client/PersistentCharacter/PersistentCharacter.test.tsx
  function LibrariesCommanderHarness (line 21) | function LibrariesCommanderHarness(props: {
  function PersistentCharacterLibraryHarness (line 34) | function PersistentCharacterLibraryHarness(props: {
  function savePersistentCharacterWithName (line 66) | async function savePersistentCharacterWithName(name: string) {

FILE: client/PlayerView/CSSFrom.ts
  function CSSFrom (line 4) | function CSSFrom(

FILE: client/PlayerView/PlayerViewClient.ts
  class PlayerViewClient (line 8) | class PlayerViewClient {
    method constructor (line 9) | constructor(private socket: Socket) {}
    method DisplayCombatStats (line 11) | public DisplayCombatStats(encounterId: string, stats: CombatStats) {
    method JoinEncounter (line 15) | public JoinEncounter(encounterId: string): any {
    method UpdateSettings (line 34) | public UpdateSettings(

FILE: client/PlayerView/ReactPlayerView.tsx
  class ReactPlayerView (line 15) | class ReactPlayerView {
    method constructor (line 19) | constructor(
    method LoadEncounterFromServer (line 29) | public async LoadEncounterFromServer() {
    method ConnectToSocket (line 43) | public ConnectToSocket(socket: Socket) {
    method renderPlayerView (line 73) | private renderPlayerView(newState: PlayerViewState) {

FILE: client/PlayerView/components/CombatFooter.tsx
  class CombatFooter (line 7) | class CombatFooter extends React.Component<
    method constructor (line 13) | constructor(props) {
    method render (line 20) | public render() {
    method componentDidUpdate (line 35) | public componentDidUpdate(prevProps: CombatFooterProps) {
    method componentDidMount (line 39) | public componentDidMount() {
    method componentWillUnmount (line 43) | public componentWillUnmount() {
    method getTimerReadout (line 47) | private getTimerReadout() {
    method resetTimerIfCombatantChanged (line 53) | private resetTimerIfCombatantChanged(prevActiveCombatantId) {
  type CombatFooterProps (line 76) | interface CombatFooterProps {
  type CombatFooterState (line 81) | interface CombatFooterState {

FILE: client/PlayerView/components/CombatStatsPopup.tsx
  class CombatStatsPopup (line 5) | class CombatStatsPopup extends React.Component<CombatStatsProps> {
    method render (line 13) | public render() {
  type CombatStatsProps (line 75) | interface CombatStatsProps {

FILE: client/PlayerView/components/CustomStyles.tsx
  class CustomStyles (line 5) | class CustomStyles extends React.Component<{
    method render (line 10) | public render() {

FILE: client/PlayerView/components/DamageSuggestor.tsx
  class DamageSuggestor (line 5) | class DamageSuggestor extends React.Component<DamageSuggestorProps> {
    method render (line 12) | public render() {
  type ApplyDamageCallback (line 34) | type ApplyDamageCallback = (
  type DamageSuggestorProps (line 39) | interface DamageSuggestorProps {

FILE: client/PlayerView/components/PlayerView.tsx
  type LocalState (line 18) | interface LocalState {
  type OwnProps (line 29) | interface OwnProps {
  type PlayerViewProps (line 35) | type PlayerViewProps = PlayerViewState & OwnProps;
  class PlayerView (line 36) | class PlayerView extends React.Component<PlayerViewProps, LocalState> {
    method constructor (line 39) | constructor(props) {
    method render (line 53) | public render() {
    method componentDidUpdate (line 153) | public componentDidUpdate(prevProps: PlayerViewState) {
    method splashPortraitIfNeeded (line 159) | private splashPortraitIfNeeded(previousActiveCombatantId) {
    method showCombatStatsIfNeeded (line 191) | private showCombatStatsIfNeeded(prevStats: CombatStats) {
    method scrollToActiveCombatant (line 203) | private scrollToActiveCombatant() {

FILE: client/PlayerView/components/PlayerViewCombatant.tsx
  type PlayerViewCombatantProps (line 6) | interface PlayerViewCombatantProps {
  class PlayerViewCombatant (line 19) | class PlayerViewCombatant extends React.Component<PlayerViewCombatantPro...
    method render (line 20) | public render() {

FILE: client/PlayerView/components/PortraitModal.tsx
  class PortraitWithCaption (line 3) | class PortraitWithCaption extends React.Component<PortraitWithCaptionPro...
    method render (line 4) | public render() {
  type PortraitWithCaptionProps (line 17) | interface PortraitWithCaptionProps {

FILE: client/PlayerView/components/SpentReactionIndicator.tsx
  function SpentReactionIndicator (line 4) | function SpentReactionIndicator(): JSX.Element {

FILE: client/PlayerView/components/TagSuggestor.tsx
  class TagSuggestor (line 8) | class TagSuggestor extends React.Component<TagSuggestorProps> {
    method render (line 9) | public render() {
  type ApplyTagCallback (line 69) | type ApplyTagCallback = (tagState: TagState) => void;
  type TagSuggestorProps (line 71) | interface TagSuggestorProps {

FILE: client/Prompts/AcceptDamagePrompt.tsx
  type AcceptDamageModel (line 8) | type AcceptDamageModel = {
  function AcceptDamagePrompt (line 12) | function AcceptDamagePrompt(

FILE: client/Prompts/AcceptTagPrompt.tsx
  type AcceptTagModel (line 11) | type AcceptTagModel = {
  function AcceptTagPrompt (line 15) | function AcceptTagPrompt(

FILE: client/Prompts/ApplyDamagePrompt.tsx
  type ApplyDamageModel (line 8) | interface ApplyDamageModel {

FILE: client/Prompts/ApplyHealingPrompt.tsx
  type ApplyHealingModel (line 9) | interface ApplyHealingModel {

FILE: client/Prompts/ApplyTemporaryHPPrompt.tsx
  type ApplyTemporaryHPModel (line 7) | type ApplyTemporaryHPModel = { hpAmount: number };
  function ApplyTemporaryHPPrompt (line 9) | function ApplyTemporaryHPPrompt(

FILE: client/Prompts/ConcentrationPrompt.tsx
  type ConcentationModel (line 8) | type ConcentationModel = {
  function ConcentrationPrompt (line 14) | function ConcentrationPrompt(

FILE: client/Prompts/ConditionReferencePrompt.tsx
  function ConditionReferencePrompt (line 8) | function ConditionReferencePrompt(

FILE: client/Prompts/EditAliasPrompt.tsx
  type EditAliasModel (line 9) | type EditAliasModel = { alias: string };
  function EditAliasPrompt (line 11) | function EditAliasPrompt(

FILE: client/Prompts/EditInitiativePrompt.tsx
  type EditInitiativeModel (line 10) | type EditInitiativeModel = { initiativeRoll: number; breakLink: boolean };
  function EditInitiativePrompt (line 12) | function EditInitiativePrompt(

FILE: client/Prompts/InitiativePrompt.tsx
  type InitiativePromptComponentProps (line 13) | interface InitiativePromptComponentProps {
  function InitiativePromptComponent (line 18) | function InitiativePromptComponent(props: InitiativePromptComponentProps) {
  function combatantInitiativeField (line 35) | function combatantInitiativeField(combatant: Combatant) {
  type InitiativeModel (line 74) | type InitiativeModel = {
  function InitiativePrompt (line 80) | function InitiativePrompt(

FILE: client/Prompts/LinkInitiativePrompt.tsx
  function LinkInitiativePrompt (line 6) | function LinkInitiativePrompt(onDismiss: () => void): PromptProps<{}> {

FILE: client/Prompts/MoveEncounterPrompt.tsx
  type MoveEncounterPromptProps (line 12) | interface MoveEncounterPromptProps {
  function MoveEncounterPromptComponent (line 18) | function MoveEncounterPromptComponent(props: MoveEncounterPromptProps) {
  type MoveEncounterModel (line 35) | type MoveEncounterModel = {
  function MoveEncounterPrompt (line 40) | function MoveEncounterPrompt(

FILE: client/Prompts/PendingPrompts.tsx
  type PromptProps (line 4) | interface PromptProps<T extends object> {
  class Prompt (line 11) | class Prompt<T extends object> extends React.Component<
    method render (line 18) | public render() {
    method componentDidMount (line 44) | public componentDidMount() {
  type PendingPromptsProps (line 71) | interface PendingPromptsProps {
  class PendingPrompts (line 76) | class PendingPrompts extends React.Component<PendingPromptsProps> {
    method render (line 77) | public render() {

FILE: client/Prompts/PlayerViewPrompt.tsx
  type PlayerViewPromptComponentProps (line 12) | interface PlayerViewPromptComponentProps {
  function PlayerViewPromptComponent (line 17) | function PlayerViewPromptComponent(props: PlayerViewPromptComponentProps) {
  function useCopyableText (line 89) | function useCopyableText() {
  function CustomEncounterId (line 108) | function CustomEncounterId(props: {
  type PlayerViewPromptModel (line 159) | interface PlayerViewPromptModel {
  function PlayerViewPrompt (line 163) | function PlayerViewPrompt(

FILE: client/Prompts/PrivacyPolicyPrompt.tsx
  function PrivacyPolicyComponent (line 12) | function PrivacyPolicyComponent() {
  function PrivacyPolicyPrompt (line 58) | function PrivacyPolicyPrompt(): PromptProps<{ optIn: boolean }> {

FILE: client/Prompts/QuickAddPrompt.tsx
  type QuickAddModel (line 8) | type QuickAddModel = {
  function QuickAddPrompt (line 15) | function QuickAddPrompt(

FILE: client/Prompts/QuickEditStatBlockPrompt.tsx
  type QuickAddModel (line 9) | type QuickAddModel = {
  function QuickEditStatBlockPrompt (line 15) | function QuickEditStatBlockPrompt(

FILE: client/Prompts/RollDicePrompt.tsx
  type RollDiceModel (line 11) | interface RollDiceModel {

FILE: client/Prompts/SaveEncounterPrompt.tsx
  function SaveEncounterPromptComponent (line 17) | function SaveEncounterPromptComponent(props: { autocompletePaths: string...
  type CombatantInclusionModel (line 94) | interface CombatantInclusionModel {
  type SaveEncounterModel (line 100) | interface SaveEncounterModel {
  function SaveEncounterPrompt (line 108) | function SaveEncounterPrompt(

FILE: client/Prompts/SpellPrompt.tsx
  function SpellPrompt (line 10) | function SpellPrompt(
  function SpellPromptComponent (line 21) | function SpellPromptComponent(props: {

FILE: client/Prompts/StandardPromptLayout.tsx
  type Props (line 5) | type Props = {
  function StandardPromptLayout (line 12) | function StandardPromptLayout(props: Props) {

FILE: client/Prompts/TagPrompt.tsx
  type TagPromptProps (line 17) | interface TagPromptProps {
  type TagPromptState (line 23) | interface TagPromptState {
  class TagPromptComponent (line 27) | class TagPromptComponent extends React.Component<
    method constructor (line 31) | constructor(props) {
    method render (line 39) | public render() {
  type TagModel (line 139) | interface TagModel {
  function TagPrompt (line 148) | function TagPrompt(

FILE: client/Prompts/UpdateNotesPrompt.tsx
  function UpdateNotesPromptComponent (line 9) | function UpdateNotesPromptComponent() {
  type NotesModel (line 23) | interface NotesModel {
  function UpdateNotesPrompt (line 27) | function UpdateNotesPrompt(

FILE: client/Reducers/Actions.ts
  type Action (line 4) | type Action = EncounterAction | CombatantAction;

FILE: client/Reducers/CombatantActions.tsx
  type CombatantAction (line 3) | type CombatantAction = BaseCombatantAction &
  type BaseCombatantAction (line 6) | type BaseCombatantAction = {
  type SetStatBlock (line 10) | type SetStatBlock = {
  type ApplyDamage (line 15) | type ApplyDamage = {

FILE: client/Reducers/CombatantsReducer.tsx
  function CombatantsReducer (line 6) | function CombatantsReducer(state: CombatantState[], action: Action) {

FILE: client/Reducers/EncounterActions.tsx
  type EncounterAction (line 4) | type EncounterAction =
  type AddCombatantFromState (line 16) | type AddCombatantFromState = {
  type AddCombatantFromStatBlock (line 23) | type AddCombatantFromStatBlock = {
  type RemoveCombatant (line 32) | type RemoveCombatant = {
  type StartEncounter (line 39) | type StartEncounter = {
  type EndEncounter (line 46) | type EndEncounter = {
  type NextTurn (line 50) | type NextTurn = {
  type PreviousTurn (line 54) | type PreviousTurn = {
  type ClearEncounter (line 58) | type ClearEncounter = {
  type CleanEncounter (line 62) | type CleanEncounter = {
  type RestoreAllPlayerCharacterHP (line 66) | type RestoreAllPlayerCharacterHP = {

FILE: client/Reducers/EncounterReducer.test.tsx
  function BuildActiveEncounter (line 75) | function BuildActiveEncounter() {

FILE: client/Reducers/EncounterReducer.tsx
  function EncounterReducer (line 10) | function EncounterReducer(

FILE: client/Reducers/GetCombatantsSorted.tsx
  function GetCombatantsSorted (line 7) | function GetCombatantsSorted(
  function getCombatantSortIteratees (line 17) | function getCombatantSortIteratees(
  function getGroupBonusForCombatant (line 36) | function getGroupBonusForCombatant(
  function computeInitiativeBonus (line 52) | function computeInitiativeBonus(statBlock: StatBlock) {

FILE: client/Reducers/InitializeCombatantFromStatBlock.tsx
  function InitializeCombatantFromStatBlock (line 4) | function InitializeCombatantFromStatBlock(

FILE: client/Rules/Dice.ts
  class Dice (line 3) | class Dice {

FILE: client/Rules/RollResult.ts
  class RollResult (line 1) | class RollResult {
    method constructor (line 2) | constructor(
    method Maximum (line 7) | get Maximum(): number {
    method Total (line 10) | get Total(): number {
    method String (line 13) | get String(): string {
    method FormattedString (line 23) | get FormattedString(): string {

FILE: client/Rules/Rules.ts
  type IRules (line 4) | interface IRules {
  class DefaultRules (line 11) | class DefaultRules implements IRules {

FILE: client/Settings/Settings.ts
  function applyNewCommandSettings (line 11) | function applyNewCommandSettings(newSettings: Settings, commands: Comman...
  function UpdateSettings (line 35) | function UpdateSettings(oldSettings: any): Settings {
  function InitializeSettings (line 53) | function InitializeSettings() {
  function SubscribeCommandsToSettingsChanges (line 73) | function SubscribeCommandsToSettingsChanges(commands: Command[]) {
  function SubscribeToDarkModeChanges (line 80) | function SubscribeToDarkModeChanges() {
  function UpdateLegacyCommandSettingsAndSave (line 90) | function UpdateLegacyCommandSettingsAndSave(

FILE: client/Settings/components/About.tsx
  type AboutProps (line 6) | interface AboutProps {
  class About (line 11) | class About extends React.Component<AboutProps> {
    method render (line 12) | public render() {

FILE: client/Settings/components/AccountSettings.tsx
  type AccountSettingsProps (line 8) | interface AccountSettingsProps {
  class AccountSettings (line 13) | class AccountSettings extends React.Component<AccountSettingsProps> {
    method render (line 14) | public render() {

FILE: client/Settings/components/AccountSyncSettings.tsx
  type AccountSyncSettingsProps (line 18) | interface AccountSyncSettingsProps {
  type AccountSyncSettingsState (line 23) | interface AccountSyncSettingsState {
  class AccountSyncSettings (line 27) | class AccountSyncSettings extends React.Component<
    method constructor (line 31) | constructor(props) {
    method render (line 38) | public render() {
    method loginMessage (line 94) | private loginMessage() {
    method noSyncMessage (line 109) | private noSyncMessage() {
    method getCounts (line 195) | private getCounts<T extends Listable>(items: Listing<T>[]) {

FILE: client/Settings/components/ColorBlock.tsx
  type ColorBlockProps (line 3) | interface ColorBlockProps {
  class ColorBlock (line 8) | class ColorBlock extends React.Component<ColorBlockProps, {}> {
    method render (line 9) | public render() {

FILE: client/Settings/components/CommandsSettings.tsx
  type CommandSettingRowProps (line 13) | type CommandSettingRowProps = {
  function CommandSettingRow (line 18) | function CommandSettingRow(props: CommandSettingRowProps) {
  type CommandsSettingsProps (line 47) | type CommandsSettingsProps = {
  function CommandsSettings (line 52) | function CommandsSettings(props: CommandsSettingsProps) {
  function buildCommandSettingRow (line 72) | function buildCommandSettingRow(

FILE: client/Settings/components/ContentSettings.tsx
  function ContentSettings (line 7) | function ContentSettings() {

FILE: client/Settings/components/DisplaysToggle.tsx
  function DisplaysToggle (line 6) | function DisplaysToggle(props: {
  function DisplaysToggleHeader (line 42) | function DisplaysToggleHeader() {

FILE: client/Settings/components/Dropdown.tsx
  function SelectOptions (line 16) | function SelectOptions(props: { fieldName: string; options: {} }) {

FILE: client/Settings/components/EpicInitiativeSettings.tsx
  function EpicInitiativeSettings (line 10) | function EpicInitiativeSettings() {
  function loginMessage (line 92) | function loginMessage() {
  function upgradeMessage (line 106) | function upgradeMessage() {
  function epicInitiativeFeatures (line 134) | function epicInitiativeFeatures() {

FILE: client/Settings/components/LocalDataSettings.tsx
  class LocalDataSettings (line 8) | class LocalDataSettings extends React.Component {
    method render (line 9) | public render() {

FILE: client/Settings/components/OptionsSettings.tsx
  function OptionsSettings (line 15) | function OptionsSettings(props: {

FILE: client/Settings/components/SettingsPane.tsx
  type SettingsPaneProps (line 28) | interface SettingsPaneProps {
  function SettingsPane (line 39) | function SettingsPane(props: SettingsPaneProps) {

FILE: client/Settings/components/StylesChooser.tsx
  type ColorChooserProps (line 8) | interface ColorChooserProps {}
  type ColorChooserState (line 9) | interface ColorChooserState {
  class StylesChooser (line 13) | class StylesChooser extends React.Component<
    method constructor (line 17) | constructor(props) {
    method render (line 24) | public render() {
    method getLabelAndColorBlock (line 64) | private getLabelAndColorBlock(
    method bindClickToSelectStyle (line 99) | private bindClickToSelectStyle(style: keyof PlayerViewCustomStyles) {

FILE: client/Settings/components/TipCarousel.tsx
  type TipCarouselProps (line 4) | interface TipCarouselProps {}
  type TipCarouselState (line 6) | interface TipCarouselState {
  class TipCarousel (line 10) | class TipCarousel extends React.Component<
    method constructor (line 14) | constructor(props) {
    method render (line 21) | public render() {

FILE: client/Settings/components/Toggle.tsx
  type ToggleButtonProps (line 4) | interface ToggleButtonProps {
  class ToggleButton (line 10) | class ToggleButton extends React.Component<ToggleButtonProps> {
    method render (line 11) | public render() {
  type ToggleProps (line 46) | interface ToggleProps {
  class Toggle (line 51) | class Toggle extends React.Component<ToggleProps> {
    method render (line 52) | public render() {

FILE: client/StatBlockEditor/ConvertStringsToNumbersWhereNeeded.tsx
  function castToNumberOrZero (line 16) | function castToNumberOrZero(value?: any) {

FILE: client/StatBlockEditor/EnumToggle.tsx
  class EnumToggle (line 5) | class EnumToggle extends React.Component<EnumToggleProps> {
    method render (line 6) | public render() {
  type EnumToggleProps (line 34) | interface EnumToggleProps {

FILE: client/StatBlockEditor/SavedEncounterEditor.tsx
  function SavedEncounterEditor (line 10) | function SavedEncounterEditor(props: {

FILE: client/StatBlockEditor/SpellEditor.tsx
  type SpellEditorProps (line 9) | type SpellEditorProps = {
  function SpellEditor (line 16) | function SpellEditor(props: SpellEditorProps) {
  function StandardEditor (line 98) | function StandardEditor() {
  function FieldRow (line 120) | function FieldRow(props: { label: string; name: string }) {

FILE: client/StatBlockEditor/StatBlockEditor.test.tsx
  constant CURRENT_APP_VERSION (line 10) | const CURRENT_APP_VERSION = require("../../package.json").version;

FILE: client/StatBlockEditor/StatBlockEditor.tsx
  type StatBlockEditorTarget (line 27) | type StatBlockEditorTarget =
  type StatBlockEditorProps (line 32) | interface StatBlockEditorProps {
  type StatBlockEditorState (line 43) | interface StatBlockEditorState {
  class StatBlockEditor (line 48) | class StatBlockEditor extends React.Component<
    method constructor (line 52) | constructor(props) {
    method componentDidCatch (line 57) | public componentDidCatch(error, info) {
    method render (line 64) | public render() {

FILE: client/StatBlockEditor/components/AutoHideField.tsx
  function AutoHideField (line 5) | function AutoHideField(props: AutoHideFieldProps) {
  type AutoHideFieldProps (line 15) | interface AutoHideFieldProps {
  function InnerAutoHideField (line 22) | function InnerAutoHideField(

FILE: client/StatBlockEditor/components/AutocompleteTextInput.tsx
  class InnerAwesomeplete (line 7) | class InnerAwesomeplete extends React.Component<{
    method componentDidMount (line 15) | public componentDidMount() {
    method render (line 38) | public render() {
  function AutocompleteTextInput (line 49) | function AutocompleteTextInput(props: {

FILE: client/StatBlockEditor/components/IdentityFields.tsx
  type IdentityFieldsProps (line 11) | interface IdentityFieldsProps {
  class IdentityFields (line 20) | class IdentityFields extends React.Component<IdentityFieldsProps> {
    method constructor (line 23) | constructor(props) {
    method render (line 31) | public render() {

FILE: client/StatBlockEditor/components/KeywordField.tsx
  type KeywordFieldProps (line 6) | interface KeywordFieldProps {
  function KeywordField (line 12) | function KeywordField(props: KeywordFieldProps) {

FILE: client/StatBlockEditor/components/NameAndModifierField.tsx
  type NameAndModifierFieldProps (line 6) | interface NameAndModifierFieldProps {
  function NameAndModifierField (line 12) | function NameAndModifierField(props: NameAndModifierFieldProps) {

FILE: client/StatBlockEditor/components/PowerField.tsx
  type PowerFieldProps (line 8) | interface PowerFieldProps {
  function PowerField (line 15) | function PowerField(props: PowerFieldProps) {

FILE: client/StatBlockEditor/components/SortableList.tsx
  type FormApi (line 7) | type FormApi = FormikProps<any>;
  type makeSortableComponent (line 9) | type makeSortableComponent = (
  function SortableList (line 14) | function SortableList(props: {
  function SortableListInner (line 29) | function SortableListInner(props: {

FILE: client/StatBlockEditor/components/StatBlockEditorFields.tsx
  type FormApi (line 10) | type FormApi = FormikProps<any>;
  function PowerFields (line 103) | function PowerFields(props: { api: FormApi; powerType: string }) {

FILE: client/StatBlockEditor/components/UseDragDrop.tsx
  type DraggedField (line 5) | interface DraggedField {
  function DropZone (line 10) | function DropZone(props: {

FILE: client/StatBlockEditor/components/useFocus.ts
  function useFocusIfEmpty (line 3) | function useFocusIfEmpty() {

FILE: client/TextEnricher/Counter.tsx
  function BeanCounter (line 5) | function BeanCounter(props: {
  function Counter (line 30) | function Counter(props: {

FILE: client/TextEnricher/TextEnricher.test.tsx
  function getTestSpell (line 10) | function getTestSpell() {

FILE: client/TextEnricher/TextEnricher.tsx
  type ReplaceConfig (line 20) | interface ReplaceConfig {
  class TextEnricher (line 28) | class TextEnricher {
    method constructor (line 29) | constructor(
    method applyReplacer (line 91) | private applyReplacer(
    method buildReactReplacer (line 107) | private buildReactReplacer(

FILE: client/TrackerViewModel.tsx
  class TrackerViewModel (line 46) | class TrackerViewModel {
    method constructor (line 83) | constructor(private Socket: SocketIOClient.Socket) {
    method EditStatBlock (line 154) | public EditStatBlock(props: Omit<StatBlockEditorProps, "onClose">): vo...
    method EditSpell (line 161) | public EditSpell(props: Omit<SpellEditorProps, "onClose">): void {
    method EditPersistentCharacterStatBlock (line 168) | public async EditPersistentCharacterStatBlock(
    method editImportedStatBlock (line 296) | private editImportedStatBlock(parsedPayload: {}) {
    method editImportedSpell (line 341) | private editImportedSpell(parsedPayload: {}) {
    method joinPlayerViewEncounter (line 399) | private joinPlayerViewEncounter() {
    method LoadAutoSavedEncounterIfAvailable (line 423) | public LoadAutoSavedEncounterIfAvailable(): void {
    method showPrivacyNotificationAfterTutorial (line 447) | private showPrivacyNotificationAfterTutorial() {
    method SaveUpdatedSettings (line 476) | public SaveUpdatedSettings(newSettings: Settings): void {

FILE: client/Tutorial/Tutorial.tsx
  function Tutorial (line 10) | function Tutorial(props: { onClose: () => void }): JSX.Element {

FILE: client/Tutorial/TutorialSteps.ts
  type Position (line 3) | interface Position {
  type TutorialStep (line 8) | interface TutorialStep {
  function getLocation (line 15) | function getLocation(element: HTMLElement) {

FILE: client/Utility/CustomBindingHandlers.ts
  function RegisterBindingHandlers (line 5) | function RegisterBindingHandlers() {

FILE: client/Utility/GetAlphaSortableLevelString.ts
  function GetAlphaSortableLevelString (line 3) | function GetAlphaSortableLevelString(level: string) {

FILE: client/Utility/LegacySynchronousLocalStore.ts
  function MigrateItemsToStore (line 20) | async function MigrateItemsToStore() {
  function List (line 34) | function List(listName: string): string[] {
  function Save (line 44) | function Save<T>(listName: string, key: string, value: T) {
  function Load (line 58) | function Load<T>(listName: string, key: string): T {
  function LoadAllAndUpdateIds (line 63) | function LoadAllAndUpdateIds<T extends Listable>(
  function Delete (line 77) | function Delete(listName: string, key: string) {
  function DeleteAll (line 89) | function DeleteAll() {
  function ExportAll (line 93) | function ExportAll(additionalKeys: { [key: string]: any }) {
  function ImportAll (line 103) | function ImportAll(file: File) {
  function importList (line 126) | function importList(listName: string, importSource: any) {
  function ImportAllAndReplace (line 147) | function ImportAllAndReplace(file: File) {
  function save (line 168) | function save(key: string, value: any) {
  function load (line 172) | function load(key: string) {

FILE: client/Utility/Metrics.ts
  class Metrics (line 10) | class Metrics {
    method TrackLoad (line 11) | public static async TrackLoad(): Promise<void> {
    method TrackEvent (line 34) | public static TrackEvent(
    method TrackAnonymousEvent (line 81) | public static TrackAnonymousEvent(
    method getLocalMeta (line 112) | private static getLocalMeta() {

FILE: client/Utility/RemovableArrayValue.ts
  class RemovableArrayValue (line 1) | class RemovableArrayValue<T> {
    method constructor (line 6) | constructor(

FILE: client/Utility/Store.ts
  function Save (line 25) | async function Save<T>(
  function Load (line 36) | async function Load<T>(listName: string, key: string): Promise<T> {
  function Count (line 40) | async function Count(listName: string): Promise<number> {
  function LoadAllAndUpdateIds (line 45) | async function LoadAllAndUpdateIds<T extends Listable>(
  function Delete (line 66) | async function Delete(listName: string, key: string): Promise<void> {
  function DeleteAll (line 72) | async function DeleteAll(): Promise<void> {
  function GetAllKeyPairs (line 79) | async function GetAllKeyPairs(): Promise<Record<string, unknown>> {
  function ImportAll (line 90) | async function ImportAll(file: File): Promise<void> {
  function importList (line 117) | async function importList(listName: string, importSource: any) {
  function ImportFromDnDAppFile (line 138) | function ImportFromDnDAppFile(file: File): void {
  function ExportListings (line 158) | async function ExportListings(
  function save (line 179) | async function save(listName: string, key: string, value) {
  function load (line 184) | async function load<T>(listName: string, key: string) {

FILE: client/Utility/TransferLocalStorage.ts
  function transferLocalStorageToCanonicalUrl (line 3) | function transferLocalStorageToCanonicalUrl(canonicalUrl: string) {
  function getTransferCompleteCallback (line 15) | function getTransferCompleteCallback(canonicalUrl: string) {
  function TransferLocalStorageToCanonicalURLIfNeeded (line 29) | function TransferLocalStorageToCanonicalURLIfNeeded(

FILE: client/Utility/useAsyncListing.tsx
  function useAsyncListing (line 5) | function useAsyncListing<T extends Listable>(

FILE: client/Utility/useStoreBackedState.ts
  function useStoreBackedState (line 4) | function useStoreBackedState<T>(

FILE: client/Widgets/CombatTimer.ts
  class CombatTimer (line 3) | class CombatTimer {

FILE: client/Widgets/DifficultyCalculator.ts
  type XpThresholds (line 1) | interface XpThresholds {
  type EncounterDifficulty (line 169) | interface EncounterDifficulty {
  class DifficultyCalculator (line 175) | class DifficultyCalculator {
    method Calculate (line 176) | public static Calculate(

FILE: client/Widgets/EventLog.ts
  class EventLog (line 3) | class EventLog {

FILE: client/Widgets/GetTimerReadout.ts
  function GetTimerReadout (line 3) | function GetTimerReadout(elapsedSeconds: number) {

FILE: client/test/InitializeTestSettings.ts
  type DeepPartial (line 6) | type DeepPartial<T> = {
  function InitializeTestSettings (line 10) | function InitializeTestSettings(overrides?: DeepPartial<Settings>) {

FILE: client/test/buildEncounter.ts
  function buildEncounter (line 4) | function buildEncounter() {

FILE: common/ClientEnvironment.ts
  type ClientEnvironment (line 1) | interface ClientEnvironment {

FILE: common/CombatStats.ts
  type CombatStats (line 1) | interface CombatStats {

FILE: common/CombatantState.ts
  type TagState (line 4) | interface TagState {
  type CombatantState (line 12) | interface CombatantState {

FILE: common/CommandSetting.ts
  class CommandSetting (line 1) | class CommandSetting {

FILE: common/DurationTiming.ts
  type DurationTiming (line 1) | type DurationTiming = "StartOfTurn" | "EndOfTurn";

FILE: common/EncounterState.ts
  type EncounterState (line 1) | interface EncounterState<T> {
  function Default (line 10) | function Default<T>(): EncounterState<T> {

FILE: common/Listable.ts
  type Listable (line 1) | interface Listable {
  type FilterDimensions (line 10) | interface FilterDimensions {
  type ListingMeta (line 16) | interface ListingMeta {

FILE: common/PatreonPost.ts
  type PatreonPostAttributes (line 1) | interface PatreonPostAttributes {
  type PatreonPost (line 9) | interface PatreonPost {

FILE: common/PersistentCharacter.ts
  type PersistentCharacter (line 5) | interface PersistentCharacter {
  function Initialize (line 17) | function Initialize(statBlock: StatBlock): PersistentCharacter {

FILE: common/PlayerViewCombatantState.ts
  type PlayerViewCombatantState (line 3) | interface PlayerViewCombatantState {

FILE: common/PlayerViewSettings.ts
  type HpVerbosityOption (line 1) | enum HpVerbosityOption {
  type PlayerViewSettings (line 9) | interface PlayerViewSettings {
  type PlayerViewCustomStyles (line 28) | interface PlayerViewCustomStyles {

FILE: common/PlayerViewState.ts
  type PlayerViewState (line 6) | interface PlayerViewState {

FILE: common/SavedEncounter.ts
  type SavedEncounter (line 5) | interface SavedEncounter extends Listable {
  function GetSearchHint (line 11) | function GetSearchHint(encounterState: SavedEncounter) {
  function Default (line 14) | function Default(): SavedEncounter {

FILE: common/Settings.ts
  type AutoGroupInitiativeOption (line 4) | enum AutoGroupInitiativeOption {
  type AutoRerollInitiativeOption (line 10) | enum AutoRerollInitiativeOption {
  type PostCombatStatsOption (line 16) | enum PostCombatStatsOption {
  type CustomStatBlockField (line 23) | type CustomStatBlockField = {
  type Settings (line 32) | interface Settings {
  function getDefaultSettings (line 64) | function getDefaultSettings(): Settings {

FILE: common/Spell.ts
  type Spell (line 4) | interface Spell extends Listable {

FILE: common/StatBlock.ts
  type AbilityScores (line 6) | interface AbilityScores {
  type NameAndModifier (line 15) | interface NameAndModifier {
  type ValueAndNotes (line 20) | interface ValueAndNotes {
  type NameAndContent (line 25) | interface NameAndContent {
  type InitiativeSpecialRoll (line 31) | type InitiativeSpecialRoll = "advantage" | "disadvantage" | "take-ten";
  type StatBlock (line 33) | interface StatBlock extends Listable {

FILE: common/Toolbox.ts
  function toModifierString (line 2) | function toModifierString(number: number): string {
  function probablyUniqueString (line 9) | function probablyUniqueString(): string {
  function concatenatedStringRegex (line 22) | function concatenatedStringRegex(strings: string[]): RegExp {
  function ParseJSONOrDefault (line 32) | function ParseJSONOrDefault<T>(json: string, defaultValue: T): T {
  function normalizeChallengeRating (line 40) | function normalizeChallengeRating(challengeRating: string): string {
  type Omit (line 53) | type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

FILE: common/ValidateEncounterId.ts
  function ValidateEncounterId (line 3) | function ValidateEncounterId(id: string) {

FILE: server/InMemoryPlayerViewManager.ts
  class InMemoryPlayerViewManager (line 8) | class InMemoryPlayerViewManager implements PlayerViewManager {
    method constructor (line 13) | constructor() {}
    method Get (line 15) | public async Get(id: string) {
    method IdAvailable (line 19) | public async IdAvailable(id: string) {
    method UpdateEncounter (line 23) | public UpdateEncounter(id: string, newState: any) {
    method UpdateSettings (line 28) | public UpdateSettings(id: string, newSettings: any) {
    method InitializeNew (line 33) | public async InitializeNew() {
    method createOrGet (line 42) | private createOrGet(id: string): PlayerViewState {
    method Destroy (line 56) | public Destroy(id: string) {

FILE: server/RedisPlayerViewManager.ts
  class RedisPlayerViewManager (line 9) | class RedisPlayerViewManager implements PlayerViewManager {
    method constructor (line 10) | constructor(private redisClient: Redis) {}
    method Get (line 12) | public async Get(id: string): Promise<PlayerViewState> {
    method IdAvailable (line 28) | public async IdAvailable(id: string): Promise<boolean> {
    method UpdateEncounter (line 33) | public async UpdateEncounter(id: string, newState: any): Promise<void> {
    method UpdateSettings (line 41) | public async UpdateSettings(id: string, newSettings: any): Promise<voi...
    method InitializeNew (line 49) | public async InitializeNew(): Promise<string> {
    method Destroy (line 61) | public async Destroy(id: string): Promise<void> {

FILE: server/configureAffiliateRoutes.ts
  function configureAffiliateRoutes (line 5) | function configureAffiliateRoutes(app: express.Application) {

FILE: server/configureBasicRulesContent.ts
  function configureBasicRulesContent (line 8) | function configureBasicRulesContent(app: express.Application) {

FILE: server/configureImportRoutes.ts
  function configureImportRoutes (line 7) | function configureImportRoutes(

FILE: server/configureOpen5eContent.ts
  function configureOpen5eContent (line 10) | async function configureOpen5eContent(
  type ListingsWithSourceTitle (line 75) | type ListingsWithSourceTitle = {
  function getAllListings (line 80) | async function getAllListings(
  function getMetaForMonster (line 123) | function getMetaForMonster(r: any): ListingMeta {
  function getMetaForSpell (line 142) | function getMetaForSpell(r: any): ListingMeta {

FILE: server/dbconnection.ts
  function upsertUser (line 31) | async function upsertUser(
  function getAccount (line 77) | async function getAccount(
  function getFullAccount (line 99) | async function getFullAccount(
  function deleteAccount (line 132) | async function deleteAccount(userId: mongo.ObjectId): Promise<number> {
  function updatePersistentCharactersIfNeeded (line 148) | async function updatePersistentCharactersIfNeeded(
  function getStatBlockListings (line 177) | function getStatBlockListings(statBlocks: {
  function getSpellListings (line 195) | function getSpellListings(spells: { [key: string]: any }): ListingMeta[] {
  function getEncounterListings (line 211) | function getEncounterListings(encounters: {
  function getPersistentCharacterListings (line 232) | function getPersistentCharacterListings(persistentCharacters: {
  function setSettings (line 253) | async function setSettings(
  type EntityPath (line 282) | type EntityPath =
  function getEntity (line 289) | async function getEntity(
  function deleteEntity (line 325) | async function deleteEntity(
  function saveEntity (line 357) | async function saveEntity<T extends Listable>(
  function saveEntitySet (line 399) | async function saveEntitySet<T extends Listable>(

FILE: server/getDbConnectionString.ts
  function getDbConnectionString (line 5) | async function getDbConnectionString() {

FILE: server/library.ts
  type Combatant (line 27) | interface Combatant {
  type SavedEncounter (line 30) | interface SavedEncounter extends Listable {
  class Library (line 34) | class Library<TItem extends Listable> {
    method constructor (line 38) | constructor(
    method FromFile (line 44) | public static FromFile<I extends Listable>(
    method Add (line 66) | private Add(items: any[]) {
    method GetById (line 86) | public GetById(id: string): TItem {
    method GetListings (line 90) | public GetListings(): ListingMeta[] {

FILE: server/metrics.ts
  type Req (line 12) | type Req = Express.Request & express.Request;
  type Res (line 13) | type Res = Express.Response & express.Response;
  function configureMetricsRoutes (line 15) | function configureMetricsRoutes(app: express.Application) {

FILE: server/patreon.ts
  type Req (line 17) | type Req = Express.Request & express.Request & { rawBody: string };
  type Res (line 18) | type Res = Express.Response & express.Response;
  type Post (line 38) | interface Post {
  type Pledge (line 50) | interface Pledge {
  type PatreonCampaign (line 58) | type PatreonCampaign = {
  function configureLoginRedirect (line 62) | function configureLoginRedirect(app: express.Application): void {
  function getTokens (line 95) | async function getTokens(code: string, redirectUri: string) {
  function handleCurrentUser (line 112) | async function handleCurrentUser(
  function getEntitledTierIds (line 143) | function getEntitledTierIds(apiResponse: Record<string, any>) {
  function getUserAccountLevel (line 159) | function getUserAccountLevel(
  function updateSessionAccountFeatures (line 196) | function updateSessionAccountFeatures(
  function configureLogout (line 206) | function configureLogout(app: express.Application): void {
  function updateLatestPost (line 227) | function updateLatestPost(latestPost: { post: Post | null }) {
  function startNewsUpdates (line 242) | function startNewsUpdates(app: express.Application): void {
  function configurePatreonWebhookReceiver (line 270) | function configurePatreonWebhookReceiver(
  function handleWebhook (line 276) | async function handleWebhook(req: Req, res: Res) {
  function verifySender (line 317) | function verifySender(req: Req, res: Res, next) {
  function verifySignature (line 337) | function verifySignature(

FILE: server/playerviewmanager.test.ts
  function TestPlayerViewManagerImplementation (line 9) | function TestPlayerViewManagerImplementation(

FILE: server/playerviewmanager.ts
  type PlayerViewManager (line 5) | interface PlayerViewManager {
  function GetPlayerViewManager (line 20) | async function GetPlayerViewManager(): Promise<PlayerViewManager> {

FILE: server/routes.ts
  type Req (line 34) | type Req = Express.Request & express.Request;
  type Res (line 35) | type Res = Express.Response & express.Response;
  function setupLocalDefaultUser (line 178) | async function setupLocalDefaultUser(session: Express.Session) {
  function updateSession (line 213) | async function updateSession(session: Express.Session) {

FILE: server/server.ts
  function improvedInitiativeServer (line 15) | async function improvedInitiativeServer() {

FILE: server/sockets.ts
  type SocketWithSessionData (line 17) | interface SocketWithSessionData {
  function joinEncounter (line 46) | function joinEncounter(id: string) {
  function resetEpicInitiativeSettings (line 164) | function resetEpicInitiativeSettings(settings: PlayerViewSettings) {
  function resetEpicInitiativeEncounterFeatures (line 172) | function resetEpicInitiativeEncounterFeatures(

FILE: server/storageroutes.ts
  type Req (line 11) | type Req = Express.Request & express.Request;
  type Res (line 12) | type Res = Express.Response & express.Response;
  function configureEntityRoute (line 102) | function configureEntityRoute<T extends Listable>(

FILE: server/user.ts
  type User (line 4) | interface User {
  type AccountStatus (line 17) | enum AccountStatus {
Condensed preview — 354 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (2,095K chars).
[
  {
    "path": ".dockerignore",
    "chars": 217,
    "preview": "node_modules/*\r\ntypings/*\r\npublic/user/*\r\npublic/fonts/*\r\n*.log\r\n*.js.map\r\n*.js\r\n*.css\r\n.tscache\r\n.vscode\r\n.baseDir\r\n!Gr"
  },
  {
    "path": ".eslintignore",
    "chars": 16,
    "preview": "server/*.test.ts"
  },
  {
    "path": ".eslintrc.json",
    "chars": 1386,
    "preview": "{\n  \"root\": true,\n  \"parser\": \"@typescript-eslint/parser\",\n  \"plugins\": [\"@typescript-eslint\", \"jest\"],\n  \"extends\": [\n "
  },
  {
    "path": ".github/workflows/main.yml",
    "chars": 450,
    "preview": "name: Bump Patch Version on development branch\n\non:\n  push:\n    branches: [master]\n\njobs:\n  update-version:\n    runs-on:"
  },
  {
    "path": ".github/workflows/node.js.yml",
    "chars": 746,
    "preview": "# This workflow will do a clean install of node dependencies, build the source code and run tests across different versi"
  },
  {
    "path": ".gitignore",
    "chars": 235,
    "preview": "node_modules/*\ntypings/*\npublic/webfonts/*\n*.log\n*.js.map\n*.js\n*.js.LICENSE\n*.js.LICENSE.txt\n!jest.config.js\n*.css\n*.tmp"
  },
  {
    "path": ".nvmrc",
    "chars": 7,
    "preview": "18.15.0"
  },
  {
    "path": ".prettierrc.json",
    "chars": 210,
    "preview": "{\n  \"printWidth\": 80,\n  \"tabWidth\": 2,\n  \"useTabs\": false,\n  \"semi\": true,\n  \"singleQuote\": false,\n  \"trailingComma\": \"n"
  },
  {
    "path": ".travis.yml",
    "chars": 90,
    "preview": "language: node_js\nnode_js:\n  - \"node\"\ncache:\n  directories:\n    - $HOME/.mongodb-binaries\n"
  },
  {
    "path": ".vscode/launch.json",
    "chars": 1121,
    "preview": "{\n  // Use IntelliSense to learn about possible Node.js debug attributes.\n  // Hover to view descriptions of existing at"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 3326,
    "preview": "# Contributing to Improved Initiative\n\nThanks for your interest in contributing to Improved Initiative! It means a lot t"
  },
  {
    "path": "Dockerfile",
    "chars": 436,
    "preview": "FROM node:carbon\r\nARG NODE_ENV\r\nENV NPM_CONFIG_PREFIX=/home/node/.npm-global\r\nENV PATH=$PATH:/home/node/.npm-global/bin\r"
  },
  {
    "path": "Gruntfile.js",
    "chars": 1237,
    "preview": "const appVersion = require(\"./package.json\").version;\n\nmodule.exports = function (grunt) {\n  grunt.loadNpmTasks(\"grunt-t"
  },
  {
    "path": "PRIVACY.md",
    "chars": 1334,
    "preview": "## TERMS OF SERVICE\n\nImproved Initiative is for entertainment purposes only, and is not responsible for dead player char"
  },
  {
    "path": "Procfile",
    "chars": 26,
    "preview": "web: node server/server.js"
  },
  {
    "path": "README.md",
    "chars": 3754,
    "preview": "# improved-initiative\n\n_Combat tracker for Dungeons and Dragons (D&amp;D) 5th Edition_\n\nThe official Improved Initiative"
  },
  {
    "path": "_config.yml",
    "chars": 27,
    "preview": "theme: jekyll-theme-cayman\n"
  },
  {
    "path": "about.md",
    "chars": 278,
    "preview": "# About Improved Initiative\n\n**Improved Initiative** was created by [Evan Bailey](mailto:improvedinitiativedev@gmail.com"
  },
  {
    "path": "babel.config.json",
    "chars": 163,
    "preview": "{\n  \"presets\": [\n    [\"@babel/preset-env\", { \"targets\": { \"node\": \"current\" } }],\n    [\"@babel/preset-typescript\", { \"al"
  },
  {
    "path": "client/.baseDir.ts",
    "chars": 72,
    "preview": "// Ignore this file. See https://github.com/grunt-ts/grunt-ts/issues/77\n"
  },
  {
    "path": "client/.eslintrc.json",
    "chars": 134,
    "preview": "{\n  \"parserOptions\": {\n    \"tsconfigRootDir\": \"client\",\n    \"project\": \"./tsconfig.eslint.json\"\n  },\n  \"env\": {\n    \"es6"
  },
  {
    "path": "client/Account/Account.ts",
    "chars": 312,
    "preview": "import { ListingMeta } from \"../../common/Listable\";\nimport { Settings } from \"../../common/Settings\";\n\nexport interface"
  },
  {
    "path": "client/Account/AccountClient.test.ts",
    "chars": 2429,
    "preview": "import { Listing, ListingOrigin } from \"../Library/Listing\";\nimport { LegacySynchronousLocalStore } from \"../Utility/Leg"
  },
  {
    "path": "client/Account/AccountClient.ts",
    "chars": 7210,
    "preview": "import axios from \"axios\";\nimport * as _ from \"lodash\";\n\nimport * as retry from \"retry\";\n\nimport { Listable } from \"../."
  },
  {
    "path": "client/App.tsx",
    "chars": 4857,
    "preview": "import * as React from \"react\";\nimport { HTML5Backend } from \"react-dnd-html5-backend\";\n\nimport { TrackerViewModel } fro"
  },
  {
    "path": "client/AutosavedEncounterTest.test.tsx",
    "chars": 2386,
    "preview": "import { renderHook, act } from \"@testing-library/react-hooks\";\n\nimport { TrackerViewModel } from \"./TrackerViewModel\";\n"
  },
  {
    "path": "client/CombatFooter/CombatFooter.tsx",
    "chars": 3211,
    "preview": "import * as React from \"react\";\nimport { EventLog } from \"../Widgets/EventLog\";\nimport { Encounter } from \"../Encounter/"
  },
  {
    "path": "client/Combatant/Combatant.test.ts",
    "chars": 2246,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { Encounter } from \"../Encounter/Encounter\";\nimport { Initial"
  },
  {
    "path": "client/Combatant/Combatant.ts",
    "chars": 9986,
    "preview": "import * as ko from \"knockout\";\n\nimport { CombatantState } from \"../../common/CombatantState\";\nimport { InitiativeSpecia"
  },
  {
    "path": "client/Combatant/CombatantDetails.tsx",
    "chars": 3463,
    "preview": "import * as React from \"react\";\n\nimport { StatBlockComponent } from \"../Components/StatBlock\";\nimport { StatBlockHeader "
  },
  {
    "path": "client/Combatant/CombatantViewModel.ts",
    "chars": 5317,
    "preview": "import * as ko from \"knockout\";\nimport * as _ from \"lodash\";\n\nimport { CombatantCommander } from \"../Commands/CombatantC"
  },
  {
    "path": "client/Combatant/GetOrRollMaximumHP.test.ts",
    "chars": 1379,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { InitializeTestSettings } from \"../test/InitializeTestSettin"
  },
  {
    "path": "client/Combatant/GetOrRollMaximumHP.ts",
    "chars": 962,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { Dice } from \"../Rules/Dice\";\nimport { CurrentSettings } fro"
  },
  {
    "path": "client/Combatant/IndexLabeling.test.ts",
    "chars": 3888,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { Encounter } from \"../Encounter/Encounter\";\nimport { Initial"
  },
  {
    "path": "client/Combatant/MultipleCombatantDetails.tsx",
    "chars": 697,
    "preview": "import * as React from \"react\";\nimport { TextEnricher } from \"../TextEnricher/TextEnricher\";\nimport { CombatantDetails }"
  },
  {
    "path": "client/Combatant/Tag.ts",
    "chars": 1903,
    "preview": "import * as ko from \"knockout\";\n\nimport { TagState } from \"../../common/CombatantState\";\nimport { DurationTiming } from "
  },
  {
    "path": "client/Combatant/ToPlayerViewCombatantState.ts",
    "chars": 2688,
    "preview": "import { PlayerViewCombatantState } from \"../../common/PlayerViewCombatantState\";\nimport { env } from \"../Environment\";\n"
  },
  {
    "path": "client/Combatant/linkComponentToObservables.tsx",
    "chars": 1373,
    "preview": "import * as React from \"react\";\nimport * as ko from \"knockout\";\nimport { noop } from \"lodash\";\n\nexport function linkComp"
  },
  {
    "path": "client/Commands/BuildCombatantCommandList.ts",
    "chars": 3611,
    "preview": "import { CombatantCommander } from \"./CombatantCommander\";\nimport { Command } from \"./Command\";\n\nexport const BuildComba"
  },
  {
    "path": "client/Commands/BuildEncounterCommandList.ts",
    "chars": 3510,
    "preview": "import { Command } from \"./Command\";\nimport { EncounterCommander } from \"./EncounterCommander\";\n\nexport const BuildEncou"
  },
  {
    "path": "client/Commands/CombatantCommander.test.ts",
    "chars": 3084,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { Encounter } from \"../Encounter/Encounter\";\nimport { Initial"
  },
  {
    "path": "client/Commands/CombatantCommander.tsx",
    "chars": 15677,
    "preview": "import * as ko from \"knockout\";\nimport * as React from \"react\";\n\nimport { CombatantState, TagState } from \"../../common/"
  },
  {
    "path": "client/Commands/Command.test.ts",
    "chars": 2000,
    "preview": "import { getDefaultSettings } from \"../../common/Settings\";\nimport { LegacySynchronousLocalStore } from \"../Utility/Lega"
  },
  {
    "path": "client/Commands/Command.ts",
    "chars": 2522,
    "preview": "import * as ko from \"knockout\";\n\nimport * as _ from \"lodash\";\n\nimport { Settings } from \"../../common/Settings\";\nimport "
  },
  {
    "path": "client/Commands/CommandButton.tsx",
    "chars": 1023,
    "preview": "import { Command } from \"./Command\";\nimport { Button } from \"../Components/Button\";\nimport * as React from \"react\";\n\nimp"
  },
  {
    "path": "client/Commands/DefaultKeybindings.ts",
    "chars": 1075,
    "preview": "export const DefaultKeybindings: { [commandId: string]: string } = {\n  \"toggle-menu\": \"alt+m\",\n  \"start-encounter\": \"alt"
  },
  {
    "path": "client/Commands/EncounterCommander.test.ts",
    "chars": 7904,
    "preview": "import { PersistentCharacter } from \"../../common/PersistentCharacter\";\nimport { StatBlock } from \"../../common/StatBloc"
  },
  {
    "path": "client/Commands/EncounterCommander.ts",
    "chars": 10313,
    "preview": "import * as _ from \"lodash\";\n\nimport { CombatStats } from \"../../common/CombatStats\";\nimport { PostCombatStatsOption } f"
  },
  {
    "path": "client/Commands/GetLegacyKeyBinding.ts",
    "chars": 1647,
    "preview": "import * as _ from \"lodash\";\n\nimport { Settings } from \"../../common/Settings\";\nimport { LegacySynchronousLocalStore } f"
  },
  {
    "path": "client/Commands/LibrariesCommander.ts",
    "chars": 10382,
    "preview": "import * as ko from \"knockout\";\nimport * as _ from \"lodash\";\n\nimport { CombatantState } from \"../../common/CombatantStat"
  },
  {
    "path": "client/Commands/PromptQueue.test.ts",
    "chars": 783,
    "preview": "import { PromptQueue } from \"./PromptQueue\";\nimport { PromptProps } from \"../Prompts/PendingPrompts\";\n\nfunction MockProm"
  },
  {
    "path": "client/Commands/PromptQueue.ts",
    "chars": 584,
    "preview": "import * as ko from \"knockout\";\n\nimport { probablyUniqueString } from \"../../common/Toolbox\";\nimport { PromptProps } fro"
  },
  {
    "path": "client/Commands/ToggleFullscreen.ts",
    "chars": 369,
    "preview": "export function ToggleFullscreen() {\n  if (!FullscreenSupported()) {\n    return;\n  }\n  if (!document[\"fullscreenElement\""
  },
  {
    "path": "client/Commands/Toolbar.test.tsx",
    "chars": 1931,
    "preview": "import * as Enzyme from \"enzyme\";\nimport * as React from \"react\";\n\nimport { Button } from \"../Components/Button\";\nimport"
  },
  {
    "path": "client/Commands/Toolbar.tsx",
    "chars": 1685,
    "preview": "import * as React from \"react\";\nimport { Command } from \"./Command\";\nimport { CommandButton } from \"./CommandButton\";\n\ni"
  },
  {
    "path": "client/Components/Button.tsx",
    "chars": 2295,
    "preview": "import Tippy, { TippyProps } from \"@tippyjs/react\";\nimport * as React from \"react\";\nimport { FieldProps, Field } from \"f"
  },
  {
    "path": "client/Components/ErrorBoundary.tsx",
    "chars": 906,
    "preview": "import * as React from \"react\";\n\ninterface ErrorBoundaryProps {\n  children: React.ReactNode;\n  renderError: (error: Erro"
  },
  {
    "path": "client/Components/Info.tsx",
    "chars": 347,
    "preview": "import Tippy, { TippyProps } from \"@tippyjs/react\";\nimport * as React from \"react\";\n\nexport function Info(props: {\n  chi"
  },
  {
    "path": "client/Components/LoadingIndicator.tsx",
    "chars": 285,
    "preview": "import * as React from \"react\";\n\nexport function LoadingIndicator() {\n  return (\n    <div className=\"loading-indicator\">"
  },
  {
    "path": "client/Components/Overlay.tsx",
    "chars": 1460,
    "preview": "import * as React from \"react\";\nimport * as ReactDOM from \"react-dom\";\n\ninterface OverlayProps {\n  maxHeightPx?: number;"
  },
  {
    "path": "client/Components/StatBlock.test.tsx",
    "chars": 545,
    "preview": "import * as Enzyme from \"enzyme\";\nimport * as React from \"react\";\n\nimport { StatBlock } from \"../../common/StatBlock\";\ni"
  },
  {
    "path": "client/Components/StatBlock.tsx",
    "chars": 8175,
    "preview": "import * as React from \"react\";\nimport { StatBlock } from \"../../common/StatBlock\";\nimport {\n  TextEnricher,\n  TextEnric"
  },
  {
    "path": "client/Components/StatBlockHeader.tsx",
    "chars": 1624,
    "preview": "import * as React from \"react\";\n\ninterface StatBlockHeaderProps {\n  name: string;\n  statBlockName?: string;\n  type: stri"
  },
  {
    "path": "client/Components/Tabs.tsx",
    "chars": 719,
    "preview": "import * as React from \"react\";\n\nexport function Tabs<TKey extends string>(props: {\n  optionNamesById: Record<TKey, stri"
  },
  {
    "path": "client/Encounter/AutoPopulatedNotes.ts",
    "chars": 2006,
    "preview": "import { NameAndContent, StatBlock } from \"../../common/StatBlock\";\n\nexport function AutoPopulatedNotes(statBlock: StatB"
  },
  {
    "path": "client/Encounter/AutoRerollInitiativeOption.test.ts",
    "chars": 1651,
    "preview": "import { AutoRerollInitiativeOption } from \"../../common/Settings\";\nimport { StatBlock } from \"../../common/StatBlock\";\n"
  },
  {
    "path": "client/Encounter/Encounter.test.ts",
    "chars": 9206,
    "preview": "import { buildEncounter } from \"../test/buildEncounter\";\n\nimport { StatBlock } from \"../../common/StatBlock\";\nimport { T"
  },
  {
    "path": "client/Encounter/Encounter.ts",
    "chars": 17163,
    "preview": "import * as ko from \"knockout\";\nimport { find, max, sortBy } from \"lodash\";\nimport * as React from \"react\";\nimport * as "
  },
  {
    "path": "client/Encounter/EncounterFlow.ts",
    "chars": 4793,
    "preview": "import * as ko from \"knockout\";\n\nimport { AutoRerollInitiativeOption } from \"../../common/Settings\";\nimport { Combatant "
  },
  {
    "path": "client/Encounter/LegacyEncounter.test.ts",
    "chars": 2613,
    "preview": "import {\n  UpdateLegacyEncounterState,\n  UpdateLegacySavedEncounter\n} from \"./UpdateLegacySavedEncounter\";\n\nfunction mak"
  },
  {
    "path": "client/Encounter/UpdateLegacySavedEncounter.ts",
    "chars": 2760,
    "preview": "import { CombatantState, TagState } from \"../../common/CombatantState\";\nimport { EncounterState } from \"../../common/Enc"
  },
  {
    "path": "client/Environment.ts",
    "chars": 808,
    "preview": "import * as Sentry from \"@sentry/browser\";\nimport { ClientEnvironment } from \"../common/ClientEnvironment\";\n\nexport cons"
  },
  {
    "path": "client/GetContextualCommandSuggestion.tsx",
    "chars": 690,
    "preview": "export function GetContextualCommandSuggestion(\n  encounterEmpty: boolean,\n  librariesVisible: boolean,\n  encounterActiv"
  },
  {
    "path": "client/Importers/DnDAppFilesImporter.ts",
    "chars": 1795,
    "preview": "import * as _ from \"lodash\";\n\nimport { Spell } from \"../../common/Spell\";\nimport { StatBlock } from \"../../common/StatBl"
  },
  {
    "path": "client/Importers/Importer.ts",
    "chars": 1821,
    "preview": "import * as _ from \"lodash\";\n\nexport class Importer {\n  constructor(protected domElement: Element) {}\n\n  public getStrin"
  },
  {
    "path": "client/Importers/Open5eImporter.ts",
    "chars": 8381,
    "preview": "import * as _ from \"lodash\";\n\nimport {\n  NameAndContent,\n  NameAndModifier,\n  StatBlock\n} from \"../../common/StatBlock\";"
  },
  {
    "path": "client/Importers/SpellImporter.test.ts",
    "chars": 1256,
    "preview": "import { SpellImporter } from \"./SpellImporter\";\n\ndescribe(\"SpellImporter\", () => {\n  let spell: Element;\n\n  beforeEach("
  },
  {
    "path": "client/Importers/SpellImporter.ts",
    "chars": 1672,
    "preview": "import * as _ from \"lodash\";\n\nimport { Spell } from \"../../common/Spell\";\nimport { AccountClient } from \"../Account/Acco"
  },
  {
    "path": "client/Importers/StatBlockImporter.test.ts",
    "chars": 5262,
    "preview": "import { StatBlockImporter } from \"./StatBlockImporter\";\n\ndescribe(\"StatBlockImporter\", () => {\n  let monster: Element;\n"
  },
  {
    "path": "client/Importers/StatBlockImporter.ts",
    "chars": 3464,
    "preview": "import * as _ from \"lodash\";\nimport { StatBlock } from \"../../common/StatBlock\";\nimport { AccountClient } from \"../Accou"
  },
  {
    "path": "client/Index.ts",
    "chars": 2395,
    "preview": "import * as ko from \"knockout\";\nimport * as React from \"react\";\nimport * as SocketIOClient from \"socket.io-client\";\n\nimp"
  },
  {
    "path": "client/InitiativeList/CombatantRow.tsx",
    "chars": 10363,
    "preview": "import * as React from \"react\";\n\nimport { CombatantState } from \"../../common/CombatantState\";\nimport { Tags } from \"./T"
  },
  {
    "path": "client/InitiativeList/CommandContext.tsx",
    "chars": 830,
    "preview": "import * as React from \"react\";\n\nimport { TagState } from \"../../common/CombatantState\";\nimport { Command } from \"../Com"
  },
  {
    "path": "client/InitiativeList/InitiativeList.test.tsx",
    "chars": 1197,
    "preview": "import { render } from \"@testing-library/react\";\nimport * as React from \"react\";\n\nimport { CombatantState } from \"../../"
  },
  {
    "path": "client/InitiativeList/InitiativeList.tsx",
    "chars": 1675,
    "preview": "import * as React from \"react\";\n\nimport { CombatantState } from \"../../common/CombatantState\";\nimport { EncounterState }"
  },
  {
    "path": "client/InitiativeList/InitiativeListHeader.tsx",
    "chars": 2242,
    "preview": "import Tippy from \"@tippyjs/react\";\nimport * as React from \"react\";\nimport { SettingsContext } from \"../Settings/Setting"
  },
  {
    "path": "client/InitiativeList/InitiativeListHost.tsx",
    "chars": 3926,
    "preview": "import * as React from \"react\";\nimport { TrackerViewModel } from \"../TrackerViewModel\";\nimport { useSubscription } from "
  },
  {
    "path": "client/InitiativeList/RestoreCombatants.tsx",
    "chars": 1952,
    "preview": "import * as React from \"react\";\nimport { CommandContext } from \"./CommandContext\";\nimport { Button } from \"../Components"
  },
  {
    "path": "client/InitiativeList/Tags.tsx",
    "chars": 1725,
    "preview": "import * as React from \"react\";\n\nimport Tippy from \"@tippyjs/react\";\nimport { TagState } from \"../../common/CombatantSta"
  },
  {
    "path": "client/LauncherViewModel.ts",
    "chars": 2135,
    "preview": "import * as ko from \"knockout\";\n\nimport { env } from \"./Environment\";\nimport { LegacySynchronousLocalStore } from \"./Uti"
  },
  {
    "path": "client/Layout/BannerHost.tsx",
    "chars": 3323,
    "preview": "import * as React from \"react\";\nimport { env } from \"../Environment\";\nimport { Metrics } from \"../Utility/Metrics\";\nimpo"
  },
  {
    "path": "client/Layout/CenterColumn.tsx",
    "chars": 1742,
    "preview": "import * as React from \"react\";\nimport { TrackerViewModel } from \"../TrackerViewModel\";\nimport { useSubscription } from "
  },
  {
    "path": "client/Layout/LeftColumn.tsx",
    "chars": 2239,
    "preview": "import * as React from \"react\";\nimport { TrackerViewModel } from \"../TrackerViewModel\";\nimport { useSubscription } from "
  },
  {
    "path": "client/Layout/RightColumn.tsx",
    "chars": 583,
    "preview": "import * as React from \"react\";\nimport { TrackerViewModel } from \"../TrackerViewModel\";\nimport { SelectedCombatants } fr"
  },
  {
    "path": "client/Layout/SelectedCombatants.tsx",
    "chars": 1655,
    "preview": "import * as React from \"react\";\nimport { useSubscription } from \"../Combatant/linkComponentToObservables\";\nimport { Comb"
  },
  {
    "path": "client/Layout/ThreeColumnLayout.tsx",
    "chars": 1378,
    "preview": "import * as React from \"react\";\nimport { TrackerViewModel } from \"../TrackerViewModel\";\nimport { VerticalResizer } from "
  },
  {
    "path": "client/Layout/ToolbarHost.tsx",
    "chars": 1442,
    "preview": "import * as React from \"react\";\nimport { TrackerViewModel } from \"../TrackerViewModel\";\nimport { useSubscription } from "
  },
  {
    "path": "client/Layout/VerticalResizer.tsx",
    "chars": 1111,
    "preview": "import * as React from \"react\";\nimport { useDrag } from \"react-dnd\";\n\nexport function VerticalResizer(props: {\n  adjustW"
  },
  {
    "path": "client/Layout/centerColumnView.tsx",
    "chars": 423,
    "preview": "import { StatBlockEditorProps } from \"../StatBlockEditor/StatBlockEditor\";\nimport { SpellEditorProps } from \"../StatBloc"
  },
  {
    "path": "client/Layout/interfacePriorityClass.tsx",
    "chars": 802,
    "preview": "export function interfacePriorityClass(\n  centerColumnView: string,\n  librariesVisible: boolean,\n  hasPrompt: boolean,\n "
  },
  {
    "path": "client/Library/Components/BuildListingTree.test.tsx",
    "chars": 1747,
    "preview": "import * as React from \"react\";\n\nimport { render } from \"@testing-library/react\";\nimport { Listing } from \"../Listing\";\n"
  },
  {
    "path": "client/Library/Components/BuildListingTree.tsx",
    "chars": 2781,
    "preview": "import * as _ from \"lodash\";\nimport * as React from \"react\";\nimport { Listable } from \"../../../common/Listable\";\nimport"
  },
  {
    "path": "client/Library/Components/Folder.tsx",
    "chars": 727,
    "preview": "import * as React from \"react\";\nimport { ListingButton } from \"./ListingButton\";\n\nexport function Folder(props: { name: "
  },
  {
    "path": "client/Library/Components/LibraryFilter.tsx",
    "chars": 790,
    "preview": "import * as React from \"react\";\nimport { useRef, useCallback } from \"react\";\nimport { useEffect } from \"react\";\n\ninterfa"
  },
  {
    "path": "client/Library/Components/ListingButton.tsx",
    "chars": 820,
    "preview": "import * as React from \"react\";\n\ninterface Props {\n  text?: string;\n  buttonClass: string;\n  faClass?: string;\n  onClick"
  },
  {
    "path": "client/Library/Components/ListingRow.tsx",
    "chars": 4057,
    "preview": "import * as _ from \"lodash\";\n\nimport * as React from \"react\";\nimport { Listable } from \"../../../common/Listable\";\nimpor"
  },
  {
    "path": "client/Library/Components/PaneHeader.tsx",
    "chars": 346,
    "preview": "import * as React from \"react\";\n\nexport function PaneHeader(props: {\n  title: string;\n  fontAwesomeIcon: string;\n  butto"
  },
  {
    "path": "client/Library/Components/SpellDetails.tsx",
    "chars": 2706,
    "preview": "import * as React from \"react\";\nimport { Spell } from \"../../../common/Spell\";\nimport { TextEnricherContext } from \"../."
  },
  {
    "path": "client/Library/FilterCache.test.ts",
    "chars": 931,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { FilterCache } from \"./FilterCache\";\nimport { Listing } from"
  },
  {
    "path": "client/Library/FilterCache.ts",
    "chars": 3278,
    "preview": "import _ = require(\"lodash\");\nimport { Listable } from \"../../common/Listable\";\nimport { Listing, ListingOrigin } from \""
  },
  {
    "path": "client/Library/Libraries.ts",
    "chars": 8028,
    "preview": "import * as React from \"react\";\n\nimport axios from \"axios\";\nimport * as _ from \"lodash\";\n\nimport { Spell } from \"../../c"
  },
  {
    "path": "client/Library/Listing.ts",
    "chars": 2804,
    "preview": "import axios from \"axios\";\nimport * as ko from \"knockout\";\n\nimport * as _ from \"lodash\";\n\nimport { Listable, ListingMeta"
  },
  {
    "path": "client/Library/Manager/ActiveLibrary.tsx",
    "chars": 592,
    "preview": "import { Listable } from \"../../../common/Listable\";\nimport { LibraryType, Libraries } from \"../Libraries\";\nimport { Lib"
  },
  {
    "path": "client/Library/Manager/DeletePrompt.tsx",
    "chars": 1347,
    "preview": "import * as React from \"react\";\nimport { Listable } from \"../../../common/Listable\";\nimport { Button } from \"../../Compo"
  },
  {
    "path": "client/Library/Manager/EditorView.tsx",
    "chars": 4986,
    "preview": "import * as React from \"react\";\nimport { useState } from \"react\";\nimport { Listable } from \"../../../common/Listable\";\ni"
  },
  {
    "path": "client/Library/Manager/LibraryManager.tsx",
    "chars": 5833,
    "preview": "import * as React from \"react\";\nimport { useState } from \"react\";\nimport { Listable } from \"../../../common/Listable\";\ni"
  },
  {
    "path": "client/Library/Manager/LibraryManagerRow.tsx",
    "chars": 2979,
    "preview": "import Tippy from \"@tippyjs/react\";\nimport * as React from \"react\";\nimport { useSubscription } from \"../../Combatant/lin"
  },
  {
    "path": "client/Library/Manager/LibraryManagerToolbar.tsx",
    "chars": 529,
    "preview": "import * as React from \"react\";\nimport { Button } from \"../../Components/Button\";\n\nexport function LibraryManagerToolbar"
  },
  {
    "path": "client/Library/Manager/ListingSelectionContext.ts",
    "chars": 330,
    "preview": "import React = require(\"react\");\nimport { Selection } from \"./useSelection\";\nimport { Listing } from \"../Listing\";\n\nexpo"
  },
  {
    "path": "client/Library/Manager/MovePrompt.tsx",
    "chars": 1508,
    "preview": "import * as React from \"react\";\nimport { Listable } from \"../../../common/Listable\";\nimport { Button } from \"../../Compo"
  },
  {
    "path": "client/Library/Manager/SelectedItemsManager.tsx",
    "chars": 4358,
    "preview": "import * as React from \"react\";\nimport { saveAs } from \"browser-filesaver\";\n\nimport { useState } from \"react\";\nimport { "
  },
  {
    "path": "client/Library/Manager/SelectedItemsView.tsx",
    "chars": 1589,
    "preview": "import * as React from \"react\";\nimport { useState } from \"react\";\nimport { Listable } from \"../../../common/Listable\";\ni"
  },
  {
    "path": "client/Library/Manager/SelectedItemsViewForActiveTab.tsx",
    "chars": 2646,
    "preview": "import * as React from \"react\";\nimport { PersistentCharacter } from \"../../../common/PersistentCharacter\";\nimport { Save"
  },
  {
    "path": "client/Library/Manager/useSelection.ts",
    "chars": 1197,
    "preview": "import * as _ from \"lodash\";\n\nimport { useCallback, useState } from \"react\";\n\nexport type Selection<T> = {\n  selected: T"
  },
  {
    "path": "client/Library/ReferencePane/EncounterLibraryReferencePane.tsx",
    "chars": 2692,
    "preview": "import * as React from \"react\";\nimport { SavedEncounter } from \"../../../common/SavedEncounter\";\nimport { linkComponentT"
  },
  {
    "path": "client/Library/ReferencePane/LibraryReferencePane.tsx",
    "chars": 8755,
    "preview": "import * as React from \"react\";\nimport { Listable } from \"../../../common/Listable\";\nimport { Button } from \"../../Compo"
  },
  {
    "path": "client/Library/ReferencePane/LibraryReferencePanes.tsx",
    "chars": 4560,
    "preview": "import * as React from \"react\";\nimport { LibrariesCommander } from \"../../Commands/LibrariesCommander\";\nimport { Button "
  },
  {
    "path": "client/Library/ReferencePane/PersistentCharacterLibraryReferencePane.tsx",
    "chars": 3462,
    "preview": "import * as React from \"react\";\n\nimport { PersistentCharacter } from \"../../../common/PersistentCharacter\";\nimport { lin"
  },
  {
    "path": "client/Library/ReferencePane/SpellLibraryReferencePane.tsx",
    "chars": 2918,
    "preview": "import * as React from \"react\";\nimport { Spell } from \"../../../common/Spell\";\nimport { linkComponentToObservables } fro"
  },
  {
    "path": "client/Library/ReferencePane/StatBlockLibraryReferencePane.tsx",
    "chars": 4832,
    "preview": "import * as React from \"react\";\nimport { Settings } from \"../../../common/Settings\";\nimport { StatBlock } from \"../../.."
  },
  {
    "path": "client/Library/StatBlockLibrary.test.tsx",
    "chars": 1932,
    "preview": "import * as React from \"react\";\nimport axios from \"axios\";\nimport { act, render } from \"@testing-library/react\";\nimport "
  },
  {
    "path": "client/Library/useLibrary.ts",
    "chars": 7540,
    "preview": "import * as moment from \"moment\";\n\nimport * as React from \"react\";\n\nimport { FilterDimensions, Listable, ListingMeta } f"
  },
  {
    "path": "client/MockAccountClient.tsx",
    "chars": 442,
    "preview": "import { AccountClient } from \"./Account/AccountClient\";\nimport { getDefaultSettings } from \"../common/Settings\";\n\nexpor"
  },
  {
    "path": "client/PersistentCharacter/PersistentCharacter.test.tsx",
    "chars": 9197,
    "preview": "import * as React from \"react\";\nimport { CombatantState } from \"../../common/CombatantState\";\nimport { EncounterState } "
  },
  {
    "path": "client/PlayerView/CSSFrom.ts",
    "chars": 2265,
    "preview": "import * as Color from \"color\";\nimport { PlayerViewCustomStyles } from \"../../common/PlayerViewSettings\";\n\nexport functi"
  },
  {
    "path": "client/PlayerView/PlayerView.test.tsx",
    "chars": 6133,
    "preview": "import { fireEvent, render } from \"@testing-library/react\";\nimport * as React from \"react\";\n\nimport { TagState } from \"."
  },
  {
    "path": "client/PlayerView/PlayerViewClient.ts",
    "chars": 1247,
    "preview": "import { Socket } from \"socket.io-client\";\nimport { CombatStats } from \"../../common/CombatStats\";\nimport { EncounterSta"
  },
  {
    "path": "client/PlayerView/PlayerViewCombatantState.test.tsx",
    "chars": 2215,
    "preview": "import { HpVerbosityOption } from \"../../common/PlayerViewSettings\";\nimport { StatBlock } from \"../../common/StatBlock\";"
  },
  {
    "path": "client/PlayerView/PlayerViewEncounterState.test.tsx",
    "chars": 2552,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { Encounter } from \"../Encounter/Encounter\";\nimport { buildEn"
  },
  {
    "path": "client/PlayerView/ReactPlayerView.tsx",
    "chars": 3514,
    "preview": "import * as React from \"react\";\nimport { render as renderReact } from \"react-dom\";\n\nimport { CombatStats } from \"../../c"
  },
  {
    "path": "client/PlayerView/TurnTimer.test.tsx",
    "chars": 1896,
    "preview": "import * as Enzyme from \"enzyme\";\nimport * as React from \"react\";\n\nimport { StatBlock } from \"../../common/StatBlock\";\ni"
  },
  {
    "path": "client/PlayerView/components/CombatFooter.tsx",
    "chars": 1984,
    "preview": "import * as _ from \"lodash\";\n\nimport * as moment from \"moment\";\n\nimport * as React from \"react\";\n\nexport class CombatFoo"
  },
  {
    "path": "client/PlayerView/components/CombatStatsPopup.tsx",
    "chars": 2562,
    "preview": "import * as React from \"react\";\nimport { CombatStats } from \"../../../common/CombatStats\";\nimport { GetTimerReadout } fr"
  },
  {
    "path": "client/PlayerView/components/CustomStyles.tsx",
    "chars": 659,
    "preview": "import * as React from \"react\";\nimport { PlayerViewCustomStyles } from \"../../../common/PlayerViewSettings\";\nimport { CS"
  },
  {
    "path": "client/PlayerView/components/DamageSuggestor.tsx",
    "chars": 1266,
    "preview": "import * as React from \"react\";\nimport { PlayerViewCombatantState } from \"../../../common/PlayerViewCombatantState\";\nimp"
  },
  {
    "path": "client/PlayerView/components/PlayerView.tsx",
    "chars": 8441,
    "preview": "import * as React from \"react\";\n\nimport * as _ from \"lodash\";\n\nimport { CombatStats } from \"../../../common/CombatStats\""
  },
  {
    "path": "client/PlayerView/components/PlayerViewCombatant.tsx",
    "chars": 3280,
    "preview": "import * as React from \"react\";\n\nimport { PlayerViewCombatantState } from \"../../../common/PlayerViewCombatantState\";\nim"
  },
  {
    "path": "client/PlayerView/components/PlayerViewCombatantHeader.tsx",
    "chars": 1026,
    "preview": "import * as React from \"react\";\nimport {\n  ToggleFullscreen,\n  FullscreenSupported\n} from \"../../Commands/ToggleFullscre"
  },
  {
    "path": "client/PlayerView/components/PortraitModal.tsx",
    "chars": 574,
    "preview": "import * as React from \"react\";\n\nexport class PortraitWithCaption extends React.Component<PortraitWithCaptionProps> {\n  "
  },
  {
    "path": "client/PlayerView/components/SpentReactionIndicator.tsx",
    "chars": 389,
    "preview": "import * as React from \"react\";\nimport Tippy from \"@tippyjs/react\";\n\nexport function SpentReactionIndicator(): JSX.Eleme"
  },
  {
    "path": "client/PlayerView/components/TagSuggestor.tsx",
    "chars": 2445,
    "preview": "import { Formik, FormikProps } from \"formik\";\nimport * as React from \"react\";\nimport { TagState } from \"../../../common/"
  },
  {
    "path": "client/Prompts/AcceptDamagePrompt.tsx",
    "chars": 1998,
    "preview": "import * as React from \"react\";\nimport { CombatantViewModel } from \"../Combatant/CombatantViewModel\";\nimport { TrackerVi"
  },
  {
    "path": "client/Prompts/AcceptTagPrompt.tsx",
    "chars": 1859,
    "preview": "import * as React from \"react\";\nimport { TagState } from \"../../common/CombatantState\";\nimport { Combatant } from \"../Co"
  },
  {
    "path": "client/Prompts/ApplyDamagePrompt.tsx",
    "chars": 1278,
    "preview": "import { Field } from \"formik\";\nimport * as React from \"react\";\n\nimport { CombatantViewModel } from \"../Combatant/Combat"
  },
  {
    "path": "client/Prompts/ApplyHealingPrompt.tsx",
    "chars": 1337,
    "preview": "import * as React from \"react\";\n\nimport { Field } from \"formik\";\n\nimport { CombatantViewModel } from \"../Combatant/Comba"
  },
  {
    "path": "client/Prompts/ApplyTemporaryHPPrompt.tsx",
    "chars": 819,
    "preview": "import * as React from \"react\";\nimport { PromptProps } from \"./PendingPrompts\";\nimport { Field } from \"formik\";\nimport {"
  },
  {
    "path": "client/Prompts/CombatStatsPrompt.tsx",
    "chars": 2169,
    "preview": "import * as React from \"react\";\n\nimport { CombatStats } from \"../../common/CombatStats\";\nimport { SubmitButton } from \"."
  },
  {
    "path": "client/Prompts/ConcentrationPrompt.tsx",
    "chars": 1497,
    "preview": "import * as React from \"react\";\n\nimport { Combatant } from \"../Combatant/Combatant\";\nimport { PromptProps } from \"./Pend"
  },
  {
    "path": "client/Prompts/ConditionReferencePrompt.tsx",
    "chars": 911,
    "preview": "import * as React from \"react\";\nimport { Conditions2025 } from \"../Rules/Conditions\";\nimport * as _ from \"lodash\";\n\nimpo"
  },
  {
    "path": "client/Prompts/EditAliasPrompt.tsx",
    "chars": 1043,
    "preview": "import * as React from \"react\";\nimport { PromptProps } from \"./PendingPrompts\";\nimport { Combatant } from \"../Combatant/"
  },
  {
    "path": "client/Prompts/EditInitiativePrompt.tsx",
    "chars": 1714,
    "preview": "import * as React from \"react\";\nimport { PromptProps } from \"./PendingPrompts\";\nimport { Combatant } from \"../Combatant/"
  },
  {
    "path": "client/Prompts/InitiativePrompt.tsx",
    "chars": 3620,
    "preview": "import * as React from \"react\";\nimport { AutoGroupInitiativeOption } from \"../../common/Settings\";\nimport { toModifierSt"
  },
  {
    "path": "client/Prompts/LinkInitiativePrompt.tsx",
    "chars": 887,
    "preview": "import * as React from \"react\";\nimport { PromptProps } from \"./PendingPrompts\";\nimport { SubmitButton } from \"../Compone"
  },
  {
    "path": "client/Prompts/MoveEncounterPrompt.tsx",
    "chars": 2154,
    "preview": "import * as React from \"react\";\nimport { SavedEncounter } from \"../../common/SavedEncounter\";\nimport { AccountClient } f"
  },
  {
    "path": "client/Prompts/PendingPrompts.tsx",
    "chars": 2609,
    "preview": "import { Formik, FormikProps } from \"formik\";\nimport * as React from \"react\";\n\nexport interface PromptProps<T extends ob"
  },
  {
    "path": "client/Prompts/PlayerViewPrompt.tsx",
    "chars": 5318,
    "preview": "import { Field } from \"formik\";\nimport * as React from \"react\";\nimport { useRef, useCallback, useState } from \"react\";\ni"
  },
  {
    "path": "client/Prompts/PrivacyPolicyPrompt.tsx",
    "chars": 2269,
    "preview": "import * as React from \"react\";\nimport { LegacySynchronousLocalStore } from \"../Utility/LegacySynchronousLocalStore\";\nim"
  },
  {
    "path": "client/Prompts/QuickAddPrompt.tsx",
    "chars": 1537,
    "preview": "import * as React from \"react\";\nimport { StatBlock } from \"../../common/StatBlock\";\nimport { Metrics } from \"../Utility/"
  },
  {
    "path": "client/Prompts/QuickEditStatBlockPrompt.tsx",
    "chars": 1587,
    "preview": "import * as React from \"react\";\nimport { StatBlock } from \"../../common/StatBlock\";\nimport { Metrics } from \"../Utility/"
  },
  {
    "path": "client/Prompts/RollDicePrompt.tsx",
    "chars": 1880,
    "preview": "import { Field } from \"formik\";\nimport * as React from \"react\";\n\nimport { probablyUniqueString } from \"../../common/Tool"
  },
  {
    "path": "client/Prompts/SaveEncounterPrompt.tsx",
    "chars": 5615,
    "preview": "import { Field, FieldArray, FieldProps } from \"formik\";\nimport * as React from \"react\";\nimport { CombatantState } from \""
  },
  {
    "path": "client/Prompts/SpellPrompt.tsx",
    "chars": 1430,
    "preview": "import * as React from \"react\";\n\nimport { Spell } from \"../../common/Spell\";\nimport { SubmitButton } from \"../Components"
  },
  {
    "path": "client/Prompts/StandardPromptLayout.tsx",
    "chars": 879,
    "preview": "import * as React from \"react\";\n\nimport { SubmitButton } from \"../Components/Button\";\n\ntype Props = {\n  label: React.Rea"
  },
  {
    "path": "client/Prompts/TagPrompt.tsx",
    "chars": 7166,
    "preview": "import * as _ from \"lodash\";\nimport * as React from \"react\";\n\nimport { Field, FieldProps } from \"formik\";\nimport { Durat"
  },
  {
    "path": "client/Prompts/UpdateNotesPrompt.tsx",
    "chars": 1167,
    "preview": "import * as React from \"react\";\n\nimport { Field } from \"formik\";\nimport { Combatant } from \"../Combatant/Combatant\";\nimp"
  },
  {
    "path": "client/Reducers/Actions.ts",
    "chars": 165,
    "preview": "import { CombatantAction } from \"./CombatantActions\";\nimport { EncounterAction } from \"./EncounterActions\";\n\nexport type"
  },
  {
    "path": "client/Reducers/CombatantActions.tsx",
    "chars": 610,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\n\nexport type CombatantAction = BaseCombatantAction &\n  (SetStatBlock"
  },
  {
    "path": "client/Reducers/CombatantsReducer.test.tsx",
    "chars": 1240,
    "preview": "import { StatBlock } from \"../../common/StatBlock\";\nimport { InitializeSettings } from \"../Settings/Settings\";\nimport { "
  },
  {
    "path": "client/Reducers/CombatantsReducer.tsx",
    "chars": 970,
    "preview": "import { CombatantState } from \"../../common/CombatantState\";\nimport { Action } from \"./Actions\";\nimport { CombatantActi"
  },
  {
    "path": "client/Reducers/EncounterActions.tsx",
    "chars": 1315,
    "preview": "import { CombatantState } from \"../../common/CombatantState\";\nimport { StatBlock } from \"../../common/StatBlock\";\n\nexpor"
  },
  {
    "path": "client/Reducers/EncounterReducer.test.tsx",
    "chars": 3214,
    "preview": "import { EncounterState } from \"../../common/EncounterState\";\nimport { CombatantState } from \"../../common/CombatantStat"
  },
  {
    "path": "client/Reducers/EncounterReducer.tsx",
    "chars": 3744,
    "preview": "import { EncounterState } from \"../../common/EncounterState\";\nimport { CombatantState } from \"../../common/CombatantStat"
  },
  {
    "path": "client/Reducers/GetCombatantsSorted.tsx",
    "chars": 1715,
    "preview": "import { EncounterState } from \"../../common/EncounterState\";\nimport { CombatantState } from \"../../common/CombatantStat"
  },
  {
    "path": "client/Reducers/InitializeCombatantFromStatBlock.tsx",
    "chars": 548,
    "preview": "import { CombatantState } from \"../../common/CombatantState\";\nimport { StatBlock } from \"../../common/StatBlock\";\n\nexpor"
  },
  {
    "path": "client/Rules/Conditions.ts",
    "chars": 16656,
    "preview": "export const Conditions2014 = {\n  Blinded: `<ul>\n    <li>A blinded creature can’t see and automatically fails any abilit"
  },
  {
    "path": "client/Rules/Dice.ts",
    "chars": 1231,
    "preview": "import { RollResult } from \"./RollResult\";\n\nexport class Dice {\n  public static readonly ValidDicePattern =\n    /(\\d+)d("
  },
  {
    "path": "client/Rules/RollResult.ts",
    "chars": 1167,
    "preview": "export class RollResult {\n  constructor(\n    public Rolls: number[],\n    public Modifier: number,\n    public DieSize: nu"
  },
  {
    "path": "client/Rules/RollResults.test.ts",
    "chars": 308,
    "preview": "import { RollResult } from \"./RollResult\";\n\ndescribe(\"RollResult\", () => {\n  let roll: RollResult;\n\n  beforeEach(() => {"
  },
  {
    "path": "client/Rules/Rules.test.ts",
    "chars": 1156,
    "preview": "import { DefaultRules, IRules } from \"./Rules\";\n\ndescribe(\"DefaultRules\", () => {\n  let rules: IRules;\n\n  beforeEach(() "
  },
  {
    "path": "client/Rules/Rules.ts",
    "chars": 1474,
    "preview": "import * as _ from \"lodash\";\nimport { InitiativeSpecialRoll } from \"../../common/StatBlock\";\n\nexport interface IRules {\n"
  },
  {
    "path": "client/Settings/Settings.test.ts",
    "chars": 657,
    "preview": "import { getDefaultSettings, Settings } from \"../../common/Settings\";\nimport { LegacySynchronousLocalStore } from \"../Ut"
  },
  {
    "path": "client/Settings/Settings.ts",
    "chars": 3530,
    "preview": "import * as ko from \"knockout\";\nimport * as _ from \"lodash\";\nimport * as Mousetrap from \"mousetrap\";\n\nimport { getDefaul"
  },
  {
    "path": "client/Settings/SettingsContext.ts",
    "chars": 168,
    "preview": "import * as React from \"react\";\n\nimport { getDefaultSettings } from \"../../common/Settings\";\n\nexport const SettingsConte"
  },
  {
    "path": "client/Settings/Tips.ts",
    "chars": 2890,
    "preview": "export const tips = [\n  \"You can set keybindings and explore advanced commands on the 'Commands' tab.\",\n  \"Encounters bu"
  },
  {
    "path": "client/Settings/components/About.tsx",
    "chars": 2223,
    "preview": "import * as React from \"react\";\nimport { Button } from \"../../Components/Button\";\nimport { env } from \"../../Environment"
  },
  {
    "path": "client/Settings/components/AccountSettings.tsx",
    "chars": 702,
    "preview": "import * as React from \"react\";\n\nimport { AccountClient } from \"../../Account/AccountClient\";\nimport { Libraries } from "
  },
  {
    "path": "client/Settings/components/AccountSyncSettings.tsx",
    "chars": 6185,
    "preview": "import { saveAs } from \"browser-filesaver\";\nimport { forIn } from \"lodash\";\nimport * as React from \"react\";\n\nimport * as"
  },
  {
    "path": "client/Settings/components/ColorBlock.tsx",
    "chars": 399,
    "preview": "import * as React from \"react\";\n\ninterface ColorBlockProps {\n  color: string;\n  click: () => void;\n}\n\nexport class Color"
  },
  {
    "path": "client/Settings/components/CommandInfo.ts",
    "chars": 1565,
    "preview": "export const CommandInfoById = {\n  \"start-encounter\":\n    \"The first combatant in the initiative order will become activ"
  },
  {
    "path": "client/Settings/components/CommandsSettings.tsx",
    "chars": 2672,
    "preview": "import { Field } from \"formik\";\nimport * as _ from \"lodash\";\n\nimport * as React from \"react\";\nimport { Command } from \"."
  }
]

// ... and 154 more files (download for full content)

About this extraction

This page contains the full source code of the cynicaloptimist/improved-initiative GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 354 files (1.8 MB), approximately 508.0k tokens, and a symbol index with 772 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!