Merge branch 'master' into 022-languages

This commit is contained in:
Gesina Schwalbe 2021-01-09 00:05:36 +01:00
commit 9d58388c5f
8 changed files with 271 additions and 191 deletions

View file

@ -4,6 +4,7 @@ import { DS4ActorSheet } from "./actor/actor-sheet";
import { DS4Item } from "./item/item";
import { DS4ItemSheet } from "./item/item-sheet";
import { DS4 } from "./config";
import { DS4Check } from "./rolls/check";
Hooks.once("init", async function () {
console.log(`DS4 | Initializing the DS4 Game System\n${DS4.ASCII}`);
@ -21,6 +22,14 @@ Hooks.once("init", async function () {
CONFIG.Actor.entityClass = DS4Actor as typeof Actor;
CONFIG.Item.entityClass = DS4Item as typeof Item;
// Configure Dice
CONFIG.Dice.types = [Die, DS4Check];
CONFIG.Dice.terms = {
c: Coin,
d: Die,
s: DS4Check,
};
// Register sheet application classes
Actors.unregisterSheet("core", ActorSheet);
Actors.registerSheet("ds4", DS4ActorSheet, { makeDefault: true });

142
src/module/rolls/check.ts Normal file
View file

@ -0,0 +1,142 @@
import { RollResult, RollResultStatus } from "./roll-data";
import { ds4roll } from "./roll-executor";
interface TermData {
number: number;
faces: number;
modifiers: Array<string>;
options: Record<string, unknown>;
}
/**
* Implements DS4 Checks as an emulated "dice throw".
*
* @notes
* Be aware that, even though this behaves like one roll, it actually throws several ones internally
*
* @example
* - Roll a check against a Check Target Number (CTV) of 18: `/r dsv18`
* - Roll a check with multiple dice against a CTN of 34: `/r dsv34`
* - Roll a check with a racial ability that makes `2` a coup and `19` a fumble: `/r dsv19c2,19`
* - Roll a check with a racial ability that makes `5` a coup and default fumble: `/r dsv19c5`
* - Roll a check with exploding dice: `/r dsv34x`
*/
export class DS4Check extends DiceTerm {
constructor(termData: Partial<TermData>) {
super({
number: termData.number,
faces: termData.faces, // should be null
modifiers: termData.modifiers ?? [],
options: termData.options ?? {},
});
// Store and parse target value.
const targetValueModifier = this.modifiers.filter((m) => m[0] === "v")[0];
const tvRgx = new RegExp("v([0-9]+)?");
const tvMatch = targetValueModifier?.match(tvRgx);
if (tvMatch) {
const [parseTargetValue] = tvMatch.slice(1);
this.targetValue = parseTargetValue ? parseInt(parseTargetValue) : DS4Check.DEFAULT_TARGET_VALUE;
}
// Store and parse min/max crit
const critModifier = this.modifiers.filter((m) => m[0] === "c")[0];
const cmRgx = new RegExp("c([0-9]+)?,([0-9]+)?");
const cmMatch = critModifier?.match(cmRgx);
if (cmMatch) {
const [parseMaxCritSuccess, parseMinCritFailure] = cmMatch.slice(1);
this.maxCritSuccess = parseMaxCritSuccess
? parseInt(parseMaxCritSuccess)
: DS4Check.DEFAULT_MAX_CRIT_SUCCESS;
this.minCritFailure = parseMinCritFailure
? parseInt(parseMinCritFailure)
: DS4Check.DEFAULT_MIN_CRIT_FAILURE;
if (this.minCritFailure <= this.maxCritSuccess)
throw new SyntaxError(game.i18n.localize("DS4.ErrorDiceCritOverlap"));
}
}
success = null;
failure = null;
targetValue = DS4Check.DEFAULT_TARGET_VALUE;
minCritFailure = DS4Check.DEFAULT_MIN_CRIT_FAILURE;
maxCritSuccess = DS4Check.DEFAULT_MAX_CRIT_SUCCESS;
/**
* @override
*/
roll({ minimize = false, maximize = false } = {}): RollResult {
const rollResult = this.rollWithDifferentBorders({ minimize, maximize });
this.results.push(rollResult);
if (rollResult.status == RollResultStatus.CRITICAL_SUCCESS) {
this.success = true;
} else if (rollResult.status == RollResultStatus.CRITICAL_FAILURE) {
this.failure = true;
}
return rollResult;
}
rollWithDifferentBorders({ minimize = false, maximize = false } = {}, slayingDiceRepetition = false): RollResult {
const targetValueToUse = this.targetValue;
if (minimize) {
return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, [20], true);
} else if (maximize) {
const maximizedDice = Array(Math.ceil(targetValueToUse / 20)).fill(1);
return new RollResult(targetValueToUse, RollResultStatus.CRITICAL_SUCCESS, maximizedDice, true);
} else {
return ds4roll(targetValueToUse, {
maxCritSuccess: this.maxCritSuccess,
minCritFail: this.minCritFailure,
slayingDiceRepetition: slayingDiceRepetition,
useSlayingDice: slayingDiceRepetition,
});
}
}
/** Term Modifiers */
noop(): this {
return this;
}
// DS4 only allows recursive explosions
explode(modifier: string): this {
const rgx = /[xX]/;
const match = modifier.match(rgx);
if (!match) return this;
this.results = (this.results as Array<RollResult>)
.map((r) => {
const intermediateResults = [r];
let checked = 0;
while (checked < intermediateResults.length) {
const r = (intermediateResults as Array<RollResult>)[checked];
checked++;
if (!r.active) continue;
if (r.dice[0] <= this.maxCritSuccess) {
r.exploded = true;
const newRoll = this.rollWithDifferentBorders({}, true);
intermediateResults.push(newRoll);
}
if (checked > 1000) throw new Error(game.i18n.localize("DS4.ErrorExplodingRecursionLimitExceeded"));
}
return intermediateResults;
})
.reduce((acc, cur) => {
return acc.concat(cur);
}, []);
}
static readonly DEFAULT_TARGET_VALUE = 10;
static readonly DEFAULT_MAX_CRIT_SUCCESS = 1;
static readonly DEFAULT_MIN_CRIT_FAILURE = 20;
// TODO: add to Type declarations
static DENOMINATION = "s";
static MODIFIERS = {
x: "explode",
c: "noop", // Modifier is consumed in constructor for target value
v: "noop", // Modifier is consumed in constructor for target value
};
}

View file

@ -1,12 +1,12 @@
export interface RollOptions {
maxCritSucc: number;
maxCritSuccess: number;
minCritFail: number;
useSlayingDice: boolean;
slayingDiceRepetition: boolean;
}
export class DefaultRollOptions implements RollOptions {
public maxCritSucc = 1;
public maxCritSuccess = 1;
public minCritFail = 20;
public useSlayingDice = false;
public slayingDiceRepetition = false;
@ -17,12 +17,27 @@ export class DefaultRollOptions implements RollOptions {
}
export class RollResult {
constructor(public value: number, public status: RollResultStatus, public dice: Array<number>) {}
constructor(
public result: number,
public status: RollResultStatus,
public dice: Array<number>,
public active: boolean = true,
public exploded: boolean = false,
) {
if (this.status == RollResultStatus.CRITICAL_FAILURE) {
this.failure = true;
} else if (this.status == RollResultStatus.CRITICAL_SUCCESS) {
this.success = true;
}
}
public failure: boolean | void = undefined;
public success: boolean | void = undefined;
}
export enum RollResultStatus {
FAILURE,
SUCCESS,
CRITICAL_FAILURE,
CRITICAL_SUCCESS,
FAILURE = "FAILURE",
SUCCESS = "SUCCESS",
CRITICAL_FAILURE = "CRITICAL_FAILURE",
CRITICAL_SUCCESS = "CRITICAL_SUCCESS",
}

View file

@ -1,17 +1,22 @@
import { DefaultRollOptions, RollOptions, RollResult, RollResultStatus } from "./roll-data";
import { DS4RollProvider, RollProvider } from "./roll-provider";
import { DS4RollProvider } from "./roll-provider";
import { calculateRollResult, isDiceSwapNecessary, isSlayingDiceRepetition, separateCriticalHits } from "./roll-utils";
/**
* Performs a roll against a check target number, e.g. for usage in battle, but not for herbs.
* @param {number} checkTargetValue the final CTN, including all static modifiers.
* @param {Partial<RollOptions>} rollOptions optional, final option override that affect the checks outcome, e.g. different values for crits or whether slaying dice are used.
* @param {Array<number>} dice optional, pass already thrown dice that are used instead of rolling new ones.
*/
export function ds4roll(checkTargetValue: number, rollOptions: Partial<RollOptions> = {}): RollResult {
export function ds4roll(
checkTargetValue: number,
rollOptions: Partial<RollOptions> = {},
dice: Array<number> = null,
): RollResult {
if (checkTargetValue <= 20) {
return rollCheckSingleDie(checkTargetValue, rollOptions);
return rollCheckSingleDie(checkTargetValue, rollOptions, dice);
} else {
return rollCheckMultipleDice(checkTargetValue, rollOptions);
return rollCheckMultipleDice(checkTargetValue, rollOptions, dice);
}
}
@ -22,33 +27,34 @@ export function ds4roll(checkTargetValue: number, rollOptions: Partial<RollOptio
* This is not intended for direct usage. Use
* {@link ds4roll | the function that is not bound to an amount of Dice} instead.
*
* @remarks
* The `provider` is only exposed for testing.
*
* @param {number} checkTargetValue - The target value to check against.
* @param {RollOptions} rollOptions - Options that affect the checks outcome, e.g. different values for crits or whether slaying dice are used.
* @param {RollProvider} provider - Service providing the various, real dice throws.
* @param {Array<number>} dice optional, pass already thrown dice that are used instead of rolling new ones.
*
* @returns {RollResult} An object containing detailed information on the roll result.
*/
export function rollCheckSingleDie(
checkTargetValue: number,
rollOptions: Partial<RollOptions>,
provider: RollProvider = new DS4RollProvider(),
dice: Array<number> = null,
): RollResult {
const usedOptions = new DefaultRollOptions().mergeWith(rollOptions);
const roll = provider.getNextRoll();
const dice = [roll];
if (roll <= usedOptions.maxCritSucc) {
return new RollResult(checkTargetValue, RollResultStatus.CRITICAL_SUCCESS, dice);
} else if (roll >= usedOptions.minCritFail && !isSlayingDiceRepetition(usedOptions)) {
return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, dice);
if (dice?.length != 1) {
dice = [new DS4RollProvider().getNextRoll()];
}
const usedDice = dice;
const rolledDie = usedDice[0];
if (rolledDie <= usedOptions.maxCritSuccess) {
return new RollResult(checkTargetValue, RollResultStatus.CRITICAL_SUCCESS, usedDice, true);
} else if (rolledDie >= usedOptions.minCritFail && !isSlayingDiceRepetition(usedOptions)) {
return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, usedDice, true);
} else {
if (roll <= checkTargetValue) {
return new RollResult(roll, RollResultStatus.SUCCESS, dice);
if (rolledDie <= checkTargetValue) {
return new RollResult(rolledDie, RollResultStatus.SUCCESS, usedDice, true);
} else {
return new RollResult(0, RollResultStatus.FAILURE, dice);
return new RollResult(0, RollResultStatus.FAILURE, usedDice, true);
}
}
}
@ -60,35 +66,35 @@ export function rollCheckSingleDie(
* This is not intended for direct usage. Use
* {@link ds4roll | the function that is not bound to an amount of Dice} instead.
*
* @remarks
* The `provider` is only exposed for testing.
*
* @param {number} checkTargetValue- - The target value to check against.
* @param {number} targetValue- - The target value to check against.
* @param {RollOptions} rollOptions - Options that affect the checks outcome, e.g. different values for crits or whether slaying dice are used.
* @param {RollProvider} provider - Service providing the various, real dice throws.
* @param {Array<number>} dice - Optional array of dice values to consider instead of rolling new ones.
*
* @returns {RollResult} An object containing detailed information on the roll result.
*/
export function rollCheckMultipleDice(
targetValue: number,
rollOptions: Partial<RollOptions>,
provider: RollProvider = new DS4RollProvider(),
dice: Array<number> = null,
): RollResult {
const usedOptions = new DefaultRollOptions().mergeWith(rollOptions);
const remainderTargetValue = targetValue % 20;
const numberOfDice = Math.ceil(targetValue / 20);
const dice = provider.getNextRolls(numberOfDice);
if (!dice || dice.length != numberOfDice) {
dice = new DS4RollProvider().getNextRolls(numberOfDice);
}
const usedDice = dice;
const firstResult = dice[0];
const firstResult = usedDice[0];
const slayingDiceRepetition = isSlayingDiceRepetition(usedOptions);
// Slaying Dice require a different handling.
if (firstResult >= usedOptions.minCritFail && !slayingDiceRepetition) {
return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, dice);
return new RollResult(0, RollResultStatus.CRITICAL_FAILURE, usedDice, true);
}
const [critSuccesses, otherRolls] = separateCriticalHits(dice, usedOptions);
const [critSuccesses, otherRolls] = separateCriticalHits(usedDice, usedOptions);
const swapLastWithCrit: boolean = isDiceSwapNecessary([critSuccesses, otherRolls], remainderTargetValue);
@ -104,9 +110,9 @@ export function rollCheckMultipleDice(
const evaluationResult = calculateRollResult(sortedRollResults, remainderTargetValue, usedOptions);
if (usedOptions.useSlayingDice && firstResult <= usedOptions.maxCritSucc) {
return new RollResult(evaluationResult, RollResultStatus.CRITICAL_SUCCESS, sortedRollResults);
if (firstResult <= usedOptions.maxCritSuccess) {
return new RollResult(evaluationResult, RollResultStatus.CRITICAL_SUCCESS, usedDice, true);
} else {
return new RollResult(evaluationResult, RollResultStatus.SUCCESS, sortedRollResults);
return new RollResult(evaluationResult, RollResultStatus.SUCCESS, usedDice, true);
}
}

View file

@ -6,7 +6,8 @@
*/
export class DS4RollProvider implements RollProvider {
getNextRoll(): number {
return new Roll("1d20").roll().total;
const rand = CONFIG.Dice.randomUniform();
return Math.ceil(rand * 20);
}
getNextRolls(amount: number): Array<number> {

View file

@ -14,7 +14,7 @@ import { RollOptions } from "./roll-data";
*/
export function separateCriticalHits(dice: Array<number>, usedOptions: RollOptions): CritsAndNonCrits {
const [critSuccesses, otherRolls] = partition(dice, (v: number) => {
return v <= usedOptions.maxCritSucc;
return v <= usedOptions.maxCritSuccess;
}).map((a) => a.sort((r1, r2) => r2 - r1));
return [critSuccesses, otherRolls];
@ -112,7 +112,7 @@ export function calculateRollResult(
return rollsAndMaxValues
.map(([v, m]) => {
return v <= rollOptions.maxCritSucc ? [m, m] : [v, m];
return v <= rollOptions.maxCritSuccess ? [m, m] : [v, m];
})
.filter(([v, m]) => v <= m)
.map(([v]) => v)