diff --git a/src/config.ts b/src/config.ts index a1f2a8b73..0aa270329 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,7 @@ import { registerOracleTree } from './module/features/customoracles' import { OracleTable } from './module/roll-table/oracle-table' +import { IronswornItem } from './module/item/item' export interface EmitterEvents extends Record { highlightMove: string // Foundry UUID @@ -33,6 +34,7 @@ export type IronswornEmitter = Emitter export interface IronswornConfig { actorClass: typeof IronswornActor OracleTable: typeof OracleTable + IronswornItem: typeof IronswornItem showdown: showdown.Converter @@ -67,6 +69,7 @@ export interface IronswornConfig { export const IRONSWORN: IronswornConfig = { actorClass: IronswornActor, OracleTable, + IronswornItem, // TODO: if we wanted to implement enrichMarkdown as a showdown plugin, we could use our own instance instead. get showdown() { diff --git a/src/index.ts b/src/index.ts index 65287193c..a1daffe25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,8 @@ import type { } from '@league-of-foundry-developers/foundry-vtt-types/src/types/helperTypes' import ActorConfig from './module/actor/config' import ItemConfig from './module/item/config' +import { ProgressTrackModel } from './module/journal/subtypes/progress' +import { TruthModel } from './module/journal/subtypes/truth' declare global { interface LenientGlobalVariableTypes { @@ -83,6 +85,10 @@ Hooks.once('init', async () => { CONFIG.JournalEntry.documentClass = IronswornJournalEntry CONFIG.JournalEntryPage.documentClass = IronswornJournalPage + ;(CONFIG.JournalEntryPage as any).dataModels = { + progress: ProgressTrackModel, + truth: TruthModel + } CONFIG.RollTable.documentClass = OracleTable CONFIG.RollTable.resultIcon = 'icons/dice/d10black.svg' diff --git a/src/module/actor/sheets/sitesheet.ts b/src/module/actor/sheets/sitesheet.ts index 2578b91c8..061a12fe1 100644 --- a/src/module/actor/sheets/sitesheet.ts +++ b/src/module/actor/sheets/sitesheet.ts @@ -18,7 +18,7 @@ export class IronswornSiteSheet extends VueActorSheet { // Fetch the item. We only want to override denizens (progress-type items) const item = await Item.fromDropData(data) if (item == null) return false - if (!item.assert('progress')) { + if (!item.assert('progressTrack')) { return await super._onDropItem(event, data) } diff --git a/src/module/actor/subtypes/character.ts b/src/module/actor/subtypes/character.ts index 92614bf82..d6bf5a858 100644 --- a/src/module/actor/subtypes/character.ts +++ b/src/module/actor/subtypes/character.ts @@ -1,17 +1,17 @@ -import { StatField } from '../../fields/StatField' import { ImpactField } from '../../fields/ImpactField' -import type { IronswornActor } from '../actor' -import { ProgressTicksField } from '../../fields/ProgressTicksField' -import type { DataSchema } from '../../fields/utils' import type { ConditionMeterSource, MomentumSource } from '../../fields/MeterField' import { ConditionMeterField, MomentumField } from '../../fields/MeterField' +import { StatField } from '../../fields/StatField' +import type { LegacyTrackSource } from '../../model/LegacyTrack' +import { LegacyTrack } from '../../model/LegacyTrack' +import type { IronswornActor } from '../actor' export class CharacterModel extends foundry.abstract.TypeDataModel< CharacterDataSourceData, - CharacterDataSourceData, + CharacterDataPropertiesData, IronswornActor<'character'> > { constructor( @@ -112,30 +112,49 @@ export class CharacterModel extends foundry.abstract.TypeDataModel< custom2name: new fields.StringField({}) }), - legacies: new fields.SchemaField({ - quests: new ProgressTicksField({ - max: undefined + legacies: new fields.SchemaField({ + quests: new fields.EmbeddedDataField(LegacyTrack, { + label: 'IRONSWORN.LEGACY.Quests' }), - questsXpSpent: new fields.NumberField({ - initial: 0 + bonds: new fields.EmbeddedDataField(LegacyTrack, { + label: 'IRONSWORN.LEGACY.Bonds' }), - bonds: new ProgressTicksField({ - max: undefined - }), - bondsXpSpent: new fields.NumberField({ - initial: 0 - }), - discoveries: new ProgressTicksField({ - max: undefined - }), - discoveriesXpSpent: new fields.NumberField({ - initial: 0 + discoveries: new fields.EmbeddedDataField(LegacyTrack, { + label: 'IRONSWORN.LEGACY.Discoveries' }) }) } } + + static migrateData(source: Record) { + // @ts-expect-error + super.migrateData(source) + const migrate = foundry.abstract.Document._addDataFieldMigration + + const legacies = ['quests', 'bonds', 'discoveries'] + + for (const legacy of legacies) { + if (typeof source[legacy] === 'number') + source[legacy] = { ticks: source[legacy] } + migrate(source, `legacies.${legacy}XpSpent`, `legacies.${legacy}.xpSpent`) + } + + return source + } +} +export interface CharacterModel extends CharacterDataPropertiesData {} +export interface CharacterDataPropertiesData extends CharacterDataSourceData { + health: ConditionMeterField + spirit: ConditionMeterField + supply: ConditionMeterField + momentum: MomentumField + + legacies: { + quests: LegacyTrack + bonds: LegacyTrack + discoveries: LegacyTrack + } } -export interface CharacterModel extends CharacterDataSourceData {} export interface CharacterDataSourceData { biography: string notes: string @@ -154,13 +173,11 @@ export interface CharacterDataSourceData { momentum: MomentumSource xp: number + legacies: { - quests: number - questsXpSpent: number - bonds: number - bondsXpSpent: number - discoveries: number - discoveriesXpSpent: number + quests: LegacyTrackSource + bonds: LegacyTrackSource + discoveries: LegacyTrackSource } debility: { diff --git a/src/module/actor/subtypes/site.ts b/src/module/actor/subtypes/site.ts index 8dc8e379a..ea4c9f323 100644 --- a/src/module/actor/subtypes/site.ts +++ b/src/module/actor/subtypes/site.ts @@ -1,19 +1,20 @@ import type { TableResultDataConstructorData } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/tableResultData' -import { ChallengeRank } from '../../fields/ChallengeRank' -import { ProgressTicksField } from '../../fields/ProgressTicksField' import type { TableResultStub } from '../../fields/TableResultField' import { TableResultField } from '../../fields/TableResultField' import type { DataSchema } from '../../fields/utils' +import type { + ProgressTrackProperties, + ProgressTrackSource +} from '../../model/ProgressTrack' +import { ProgressTrack } from '../../model/ProgressTrack' import { OracleTable } from '../../roll-table/oracle-table' import type { IronswornActor } from '../actor' export class SiteModel extends foundry.abstract.TypeDataModel< SiteDataSourceData, - SiteDataSourceData, + SiteDataPropertiesData, IronswornActor<'site'> > { - static _enableV10Validation = true - get denizenTable() { return new OracleTable({ name: game.i18n.localize('IRONSWORN.DELVESITE.Denizens'), @@ -110,11 +111,34 @@ export class SiteModel extends foundry.abstract.TypeDataModel< return this.theme != null && this.domain != null } - static override defineSchema(): DataSchema { + async markProgress(times = 1) { + return await this.parent.update({ + system: { progressTrack: this.progressTrack.getMarkData(times) } + }) + } + + /** Make the Reveal a Danger move and roll a random danger from this delve site. */ + async revealADanger() { + return await (await this.getDangers())?.draw() + } + + /** Make a progress roll with the Locate Your Objective move. */ + async locateYourObjective() { + return await this.progressTrack.roll({ + actor: this.parent, + objective: this.objective + }) + } + + static override defineSchema(): DataSchema< + SiteDataSourceData, + SiteDataPropertiesData + > { const fields = foundry.data.fields return { - rank: new ChallengeRank(), - current: new ProgressTicksField(), + progressTrack: new fields.EmbeddedDataField(ProgressTrack, { + initial: { enabled: true, subtype: 'delve' } as any + }) as any, objective: new fields.HTMLField(), description: new fields.HTMLField(), notes: new fields.HTMLField(), @@ -172,17 +196,30 @@ export class SiteModel extends foundry.abstract.TypeDataModel< }) } } + + static migrateData(source) { + const migrate = foundry.abstract.Document._addDataFieldMigration + migrate(source, 'rank', 'progressTrack.rank') + migrate(source, 'current', 'progressTrack.ticks') + + return source + } } -export interface SiteModel extends SiteDataSourceData {} +export interface SiteModel extends SiteDataPropertiesData { + progressTrack: ProgressTrack +} interface SiteDataSourceData { objective: string description: string notes: string - rank: ChallengeRank.Value - current: number denizens: TableResultStub[] + progressTrack: ProgressTrackSource +} +interface SiteDataPropertiesData + extends Omit { + progressTrack: ProgressTrackProperties } export interface SiteDataSource { diff --git a/src/module/constants.ts b/src/module/constants.ts deleted file mode 100644 index d219c4ff1..000000000 --- a/src/module/constants.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ChallengeRank } from './fields/ChallengeRank' - -/** - * The number of ticks in one unit of progress. - */ -export const RANK_INCREMENTS = { - [ChallengeRank.RANK.Troublesome]: 12, - [ChallengeRank.RANK.Dangerous]: 8, - [ChallengeRank.RANK.Formidable]: 4, - [ChallengeRank.RANK.Extreme]: 2, - [ChallengeRank.RANK.Epic]: 1 -} - -/** - * The amount of legacy marked as a reward, when completing a progress track of each challenge rank. (Starforged only). - */ -export const RANK_REWARDS_SF = { - /** - * A troublesome reward that's been downgraded. - */ - 0: 0, - [ChallengeRank.RANK.Troublesome]: 1, - [ChallengeRank.RANK.Dangerous]: 2, - [ChallengeRank.RANK.Formidable]: 4, - [ChallengeRank.RANK.Extreme]: 8, - [ChallengeRank.RANK.Epic]: 12, - /** - * An epic reward that's been upgraded. - */ - 6: 16 -} diff --git a/src/module/features/chat-alert.ts b/src/module/features/chat-alert.ts index 7166c1d9e..114be2363 100644 --- a/src/module/features/chat-alert.ts +++ b/src/module/features/chat-alert.ts @@ -122,8 +122,8 @@ const ACTOR_TYPE_HANDLERS: ActorTypeHandlers = { // Starforged legacy XP for (const kind of ['quests', 'bonds', 'discoveries'] as const) { - const oldXp = actor.system.legacies[`${kind}XpSpent`] - const newXp = get(data.system, `legacies.${kind}XpSpent`) + const oldXp = actor.system.legacies[kind].xpSpent + const newXp = get(data.system, `legacies.${kind}.xpSpent`) if (newXp !== undefined) { if (newXp > oldXp) { return game.i18n.format('IRONSWORN.ChatAlert.MarkedXP', { @@ -158,53 +158,6 @@ const ACTOR_TYPE_HANDLERS: ActorTypeHandlers = { } } - const debilities = [ - 'corrupted', - 'cursed', - 'encumbered', - 'maimed', - 'shaken', - 'tormented', - 'unprepared', - 'wounded', - 'permanentlyharmed', - 'traumatized', - 'doomed', - 'indebted', - 'battered', - 'custom1', - 'custom2' - ] as const - for (const debility of debilities) { - const conditionType = gameIsStarforged ? `impact` : `debility` - const newValue = get(data.system?.debility, debility) - - if (newValue !== undefined) { - const oldValue = actor.system.debility[debility] - if (oldValue === newValue) continue - const i18nPath = `IRONSWORN.${conditionType.toUpperCase()}` - const i18nDebility = `${ - debility.startsWith('custom') - ? get(actor.system.debility, `${debility}name`) - : game.i18n.localize(`${i18nPath}.${debility.capitalize()}`) - }` - - const params = gameIsStarforged - ? { impact: i18nDebility } - : { debility: i18nDebility } - - if (newValue) - return game.i18n.format( - `IRONSWORN.ChatAlert.Marked${conditionType.capitalize()}`, - params - ) - return game.i18n.format( - `IRONSWORN.ChatAlert.Cleared${conditionType.capitalize()}`, - params - ) - } - } - return undefined }, @@ -224,36 +177,16 @@ const ACTOR_TYPE_HANDLERS: ActorTypeHandlers = { return undefined }, - starship: (actor, data) => { - const impacts = ['cursed', 'battered'] as const - for (const impact of impacts) { - const newValue = get(data.system?.debility, impact) - if (newValue !== undefined) { - const oldValue = actor.system.debility[impact] - if (oldValue === newValue) continue - const i18nImpact = game.i18n.localize( - `IRONSWORN.IMPACT.${impact.capitalize()}` - ) - const params = { impact: `${i18nImpact}` } - // TODO: use "impact" if this is an SF character - if (newValue) - return game.i18n.format('IRONSWORN.ChatAlert.MarkedImpact', params) - return game.i18n.format('IRONSWORN.ChatAlert.ClearedImpact', params) - } - } - - return undefined - }, - site: (actor, data) => { - if (data.system?.rank != null) { + if (data.system?.progressTrack.rank != null) { return game.i18n.format('IRONSWORN.ChatAlert.RankChanged', { - old: ChallengeRank.localizeValue(actor.system.rank), - new: ChallengeRank.localizeValue(data.system.rank) + old: ChallengeRank.localizeValue(actor.system.progressTrack.rank), + new: ChallengeRank.localizeValue(data.system.progressTrack.rank) }) } - if (data.system?.current !== undefined) { - const advanced = data.system.current > actor.system.current + if (data.system?.progressTrack.ticks !== undefined) { + const advanced = + data.system.progressTrack.ticks > actor.system.progressTrack.ticks return game.i18n.localize( `IRONSWORN.ChatAlert.Progress${advanced ? 'Advanced' : 'Reduced'}` ) @@ -264,22 +197,23 @@ const ACTOR_TYPE_HANDLERS: ActorTypeHandlers = { const ITEM_TYPE_HANDLERS: ItemTypeHandlers = { progress: (item, data) => { - if (data.system?.rank) { + if (data.system?.progressTrack.rank !== undefined) { return game.i18n.format('IRONSWORN.ChatAlert.rankChanged', { - old: ChallengeRank.localizeValue(item.system.rank), - new: ChallengeRank.localizeValue(data.system.rank) + old: ChallengeRank.localizeValue(item.system.progressTrack.rank), + new: ChallengeRank.localizeValue(data.system.progressTrack.rank) }) } - if (data.system?.current !== undefined) { - const advanced = data.system.current > item.system.current + if (data.system?.progressTrack.ticks !== undefined) { + const advanced = + data.system.progressTrack.ticks > item.system.progressTrack.ticks return game.i18n.localize( `IRONSWORN.ChatAlert.progress${advanced ? 'Advanced' : 'Reduced'}` ) } - if (data.system?.clockTicks !== undefined) { - const change = data.system.clockTicks - item.system.clockTicks - const advanced = data.system.clockTicks > item.system.clockTicks - const completed = data.system.clockTicks >= item.system.clockMax + if (data.system?.clock != null && item.system.clock != null) { + const change = data.system.clock.value - item.system.clock.value + const advanced = data.system.clock.value > item.system.clock.value + const completed = data.system.clock.value >= item.system.clock.max let i18nKey = 'IRONSWORN.ChatAlert.clock' switch (true) { case completed: { @@ -304,9 +238,9 @@ const ITEM_TYPE_HANDLERS: ItemTypeHandlers = { } return game.i18n.format(i18nKey, { change, - max: item.system.clockMax, - old: item.system.clockTicks, - new: data.system.clockTicks + max: item.system.clock?.max, + old: item.system.clock?.value, + new: data.system.clock?.value }) } if (data.system?.completed !== undefined) { @@ -355,7 +289,7 @@ const ITEM_TYPE_HANDLERS: ItemTypeHandlers = { ) if (selectedOption == null) return return game.i18n.format('IRONSWORN.ChatAlert.MarkedOption', { - name: selectedOption.name + name: selectedOption?.name }) } @@ -392,9 +326,9 @@ const ITEM_TYPE_HANDLERS: ItemTypeHandlers = { } } -async function sendToChat(speaker: IronswornActor, msg: string) { +export async function sendToChat(speaker: IronswornActor, msg: string) { const whisperToCurrentUser = - speaker.getFlag('foundry-ironsworn', 'muteBroadcast') ?? (false as boolean) + speaker.getFlag('foundry-ironsworn', 'muteBroadcast') ?? false const whisper = whisperToCurrentUser ? compact([game.user?.id]) : undefined const messageData: ChatMessageDataConstructorData = { diff --git a/src/module/fields/MeterField.ts b/src/module/fields/MeterField.ts index 5af5d8752..95e4610ab 100644 --- a/src/module/fields/MeterField.ts +++ b/src/module/fields/MeterField.ts @@ -58,6 +58,7 @@ export class ConditionMeterField extends MeterField { super(options, {}) } } +export interface ConditionMeterField extends MeterField, ConditionMeterSource {} export interface ConditionMeterSource extends MeterSource {} diff --git a/src/module/fields/ProgressTicksField.ts b/src/module/fields/ProgressTicksField.ts deleted file mode 100644 index 78a02b4f0..000000000 --- a/src/module/fields/ProgressTicksField.ts +++ /dev/null @@ -1,17 +0,0 @@ -export class ProgressTicksField extends foundry.data.fields.NumberField { - constructor( - options?: Omit< - Partial, - 'choices' | 'step' | 'integer' | 'min' | 'positive' - > - ) { - super({ - min: 0, - initial: 0, - max: 40, - integer: true, - ...(options as any) - }) - } -} -export interface ProgressTicksField extends foundry.data.fields.NumberField {} diff --git a/src/module/fields/types/StringField.ts b/src/module/fields/types/StringField.ts index 51c9e90a9..e55f8b4ba 100644 --- a/src/module/fields/types/StringField.ts +++ b/src/module/fields/types/StringField.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { DataModel } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/module.mjs' import type { RequireKey } from 'dataforged' declare global { @@ -96,7 +97,19 @@ declare global { any > = foundry.abstract.Document, Options extends ForeignDocumentField.Options = ForeignDocumentField.Options - > extends DocumentIdField {} + > extends DocumentIdField { + /** + * @param model - The foreign DataModel class definition which this field should link to. + * @param options - Options which configure the behavior of the field + */ + constructor( + model: ConstructorOf, + options?: Partial + ) + + model: ConstructorOf + } + // @ts-expect-error export interface ForeignDocumentField< ConcreteData extends foundry.abstract.Document< diff --git a/src/module/helpers/util.ts b/src/module/helpers/util.ts index a56ea6cfa..250132e6a 100644 --- a/src/module/helpers/util.ts +++ b/src/module/helpers/util.ts @@ -3,7 +3,7 @@ import type { KeysWithValuesOfType } from 'dataforged' /** * @remarks A document-subtype-sensitive replacement for the FVTT document deletion dialog. - * @see {@link ClientDocumentMixin#deleteDialog} + * @see {@link ClientDocumentMixin.deleteDialog} */ export async function typedDeleteDialog< T extends foundry.abstract.Document> & { diff --git a/src/module/item/config.ts b/src/module/item/config.ts index 95b86bd3f..d733384c1 100644 --- a/src/module/item/config.ts +++ b/src/module/item/config.ts @@ -25,7 +25,6 @@ import type { ProgressDataSource } from './subtypes/progress' import type { SFMoveDataProperties, SFMoveDataSource } from './subtypes/sfmove' -import { ChallengeRank } from '../fields/ChallengeRank' const dataModels: Partial< Record< @@ -80,13 +79,6 @@ const config: PartialDeep = { export default config -export interface ProgressBase { - description: string - rank: ChallengeRank.Value - current: number - completed: boolean -} - export type ItemDataSource = | AssetDataSource | ProgressDataSource @@ -117,6 +109,7 @@ declare global { expanded?: boolean muteBroadcast?: boolean 'edit-mode'?: boolean + starred?: boolean } } } diff --git a/src/module/item/item.ts b/src/module/item/item.ts index cb1ca6bda..66438d921 100644 --- a/src/module/item/item.ts +++ b/src/module/item/item.ts @@ -43,7 +43,7 @@ export class IronswornItem< return super.migrateData(data) } - override async deleteDialog(options?: Partial) { + override async deleteDialog(options: Partial = {}) { return await typedDeleteDialog(this, options) } } diff --git a/src/module/item/subtypes/asset.ts b/src/module/item/subtypes/asset.ts index 5078bac84..eff28a7a9 100644 --- a/src/module/item/subtypes/asset.ts +++ b/src/module/item/subtypes/asset.ts @@ -9,7 +9,10 @@ export class AssetModel extends foundry.abstract.TypeDataModel< > { static _enableV10Validation = true - static override defineSchema(): DataSchema { + static override defineSchema(): DataSchema< + AssetDataSourceData, + AssetDataPropertiesData + > { const fields = foundry.data.fields return { diff --git a/src/module/item/subtypes/delve-domain.ts b/src/module/item/subtypes/delve-domain.ts index 46896f611..b38daa22c 100644 --- a/src/module/item/subtypes/delve-domain.ts +++ b/src/module/item/subtypes/delve-domain.ts @@ -5,7 +5,7 @@ import type { DelveSiteDanger, DelveSiteFeature } from './common' export class DelveDomainModel extends foundry.abstract.TypeDataModel< DelveDomainDataSourceData, - DelveDomainDataSourceData, + DelveDomainDataPropertiesData, IronswornItem<'delve-domain'> > { static _enableV10Validation = true @@ -33,7 +33,10 @@ export class DelveDomainModel extends foundry.abstract.TypeDataModel< { range: [43, 45] } ] - static override defineSchema(): DataSchema { + static override defineSchema(): DataSchema< + DelveDomainDataSourceData, + DelveDomainDataPropertiesData + > { const fields = foundry.data.fields return { summary: new fields.HTMLField(), diff --git a/src/module/item/subtypes/delve-theme.ts b/src/module/item/subtypes/delve-theme.ts index e8355f5fb..559a03bd0 100644 --- a/src/module/item/subtypes/delve-theme.ts +++ b/src/module/item/subtypes/delve-theme.ts @@ -5,7 +5,7 @@ import type { DelveSiteDanger, DelveSiteFeature } from './common' export class DelveThemeModel extends foundry.abstract.TypeDataModel< DelveThemeDataSourceData, - DelveThemeDataSourceData, + DelveThemeDataPropertiesData, IronswornItem<'delve-theme'> > { static _enableV10Validation = true @@ -33,7 +33,10 @@ export class DelveThemeModel extends foundry.abstract.TypeDataModel< { range: [29, 30] } ] - static override defineSchema(): DataSchema { + static override defineSchema(): DataSchema< + DelveThemeDataSourceData, + DelveThemeDataPropertiesData + > { const fields = foundry.data.fields return { summary: new fields.HTMLField(), diff --git a/src/module/item/subtypes/progress.ts b/src/module/item/subtypes/progress.ts index 60e8fc323..39857a3bb 100644 --- a/src/module/item/subtypes/progress.ts +++ b/src/module/item/subtypes/progress.ts @@ -1,110 +1,87 @@ -import { clamp } from 'lodash-es' -import { RANK_INCREMENTS } from '../../constants' -import { ChallengeRank } from '../../fields/ChallengeRank' -import { ProgressTicksField } from '../../fields/ProgressTicksField' import type { DataSchema } from '../../fields/utils' -import { IronswornPrerollDialog } from '../../rolls' import type { IronswornItem } from '../item' -import type { ProgressBase } from '../config' +import type { ProgressTrackSource } from '../../model/ProgressTrack' +import { ProgressTrack } from '../../model/ProgressTrack' +import type { ClockSource } from '../../model/Clock' +import { Clock } from '../../model/Clock' +import type { IronswornActor } from '../../actor/actor' +/** TypeDataModel for the `progress` {@link IronswornItem} subtype. A general purpose tracker that embeds a ProgressTrack and a Clock */ export class ProgressModel extends foundry.abstract.TypeDataModel< ProgressDataSourceData, - ProgressDataSourceData, + ProgressDataPropertiesData, IronswornItem<'progress'> > { - static _enableV10Validation = true - - static readonly SCORE_MIN = 0 - static readonly SCORE_MAX = 10 - static readonly TICKS_PER_BOX = 4 - static readonly BOXES = this.SCORE_MAX - static readonly TICKS_MIN = 0 - static readonly TICKS_MAX = this.TICKS_PER_BOX * this.BOXES - - /** The derived progress score, which is an integer from 0 to 10. */ - get score() { - return Math.min( - Math.floor(this.current / ProgressModel.TICKS_PER_BOX), - ProgressModel.SCORE_MAX - ) - } - - /** The number of ticks per unit of progress (in other words, per instance of "mark progress") for this track's challenge rank. */ - get unit() { - return RANK_INCREMENTS[this.rank] - } - - /** Mark progress on this track. Use negative `units` to erase progress. - * @param units The number of units of progress to be marked (default: `1`). + /** + * Mark the progress track. Use negative `times` to erase progress. + * @param times The number of units of progress to be marked (default: `1`). */ - async markProgress(units = 1) { + async markProgress(times = 1) { return await this.parent.update({ - 'system.current': clamp( - this.current + this.unit * units, - ProgressModel.TICKS_MIN, - ProgressModel.TICKS_MAX - ) + system: { progressTrack: this.progressTrack.getMarkData(times) } }) } - async fulfill() { - let moveDfId: string | undefined - if (this.subtype === 'vow') { - const toolset = this.parent.actor?.toolset ?? 'starforged' - moveDfId = - toolset === 'starforged' - ? 'Starforged/Moves/Quest/Fulfill_Your_Vow' - : 'Ironsworn/Moves/Quest/Fulfill_Your_Vow' + /** Make a progress roll against the progress track's progress score. */ + async rollProgress({ + actor = this.parent.actor ?? undefined, + moveDfid + }: { + actor?: IronswornActor + moveDfid?: string + } = {}) { + return await this.progressTrack.roll({ + actor, + objective: this.parent.name ?? undefined, + moveDfid + }) + } + + static override migrateData(source) { + const migrate = foundry.abstract.Document._addDataFieldMigration + if (source.hasClock === true) { + migrate(source, 'hasClock', 'clock.enabled') + migrate(source, 'clockTicks', 'clock.value') + migrate(source, 'clockMax', 'clock.max') } - return await IronswornPrerollDialog.showForProgress( - this.parent.name ?? '(progress)', - this.score, - this.parent.actor ?? undefined, - moveDfId - ) - } + migrate(source, 'subtype', 'progressTrack.subtype') + migrate(source, 'starred', 'flags.foundry-ironsworn.starred') + migrate(source, 'hasTrack', 'progressTrack.enabled') + migrate(source, 'rank', 'progressTrack.rank') + migrate(source, 'current', 'progressTrack.ticks') - /** Provide a localized label for this progress track's challenge rank. */ - localizeRank() { - return ChallengeRank.localizeValue(this.rank) + return source } - static override defineSchema(): DataSchema { + static override defineSchema(): DataSchema< + ProgressDataSourceData, + ProgressDataPropertiesData + > { const fields = foundry.data.fields return { - subtype: new fields.StringField({ initial: 'progress' }), - starred: new fields.BooleanField({ initial: false }), - hasTrack: new fields.BooleanField({ initial: true }), - hasClock: new foundry.data.fields.BooleanField(), - clockTicks: new foundry.data.fields.NumberField({ - initial: 0, - integer: true, - min: 0, - max: 12 - }), - clockMax: new foundry.data.fields.NumberField({ - initial: 4, - choices: [4, 6, 8, 10, 12] + progressTrack: new fields.EmbeddedDataField(ProgressTrack, { + initial: { enabled: true } as any }), - completed: new fields.BooleanField({ initial: false }), - current: new ProgressTicksField(), - description: new fields.HTMLField(), - rank: new ChallengeRank() + clock: new fields.EmbeddedDataField(Clock), + completed: new fields.BooleanField({ required: false }), + description: new fields.HTMLField() } } } export interface ProgressModel extends ProgressDataPropertiesData {} -export interface ProgressDataSourceData extends ProgressBase { - subtype: string - starred: boolean - hasTrack: boolean - hasClock: boolean - clockTicks: number - clockMax: number +export interface ProgressDataSourceData { + description: string + progressTrack: ProgressTrackSource + completed?: boolean + clock?: ClockSource +} +export interface ProgressDataPropertiesData + extends Omit { + progressTrack: ProgressTrack + clock?: Clock } -export interface ProgressDataPropertiesData extends ProgressDataSourceData {} export interface ProgressDataSource { type: 'progress' diff --git a/src/module/journal/journal-entry-page-types.ts b/src/module/journal/journal-entry-page-types.ts index d5ed6da22..d23427959 100644 --- a/src/module/journal/journal-entry-page-types.ts +++ b/src/module/journal/journal-entry-page-types.ts @@ -1,97 +1,22 @@ -import type { ISettingTruthOption } from 'dataforged' -import type { ChallengeRank } from '../fields/ChallengeRank' import type { IronswornJournalPage } from './journal-entry-page' - -interface CounterBase { - max: number - value: number -} - -interface Threat extends CounterBase { - name: string - enabled: boolean -} - -interface Countdown extends CounterBase { - name: string - enabled: boolean -} - -/// //////// PROGRESS - -interface ProgressTrack { - ticks: number - rank: ChallengeRank.Value - /** - * For Threat/Menace from Ironsworn: Delve. - */ - threat?: Threat - /** - * For classic Ironsworn scene challenges. - */ - countdown?: Countdown -} - -export interface ProgressTrackDataSourceData extends ProgressTrack {} - -export interface ProgressTrackDataSource { - // distinguish progress types with different sheets? - type: 'progress' - system: ProgressTrackDataSourceData -} - -export interface ProgressTrackDataPropertiesData - extends ProgressTrackDataSourceData {} - -export interface ProgressTrackDataProperties { - type: 'progress' - system: ProgressTrackDataPropertiesData -} - -/// //////// CLOCKS - -export interface ClockDataSourceData extends CounterBase { - clockType: 'tension' | 'campaign' -} - -export interface ClockDataPropertiesData extends ClockDataSourceData {} - -export interface ClockDataSource { - type: 'clock' - system: ClockDataSourceData -} -export interface ClockDataProperties { - type: 'clock' - system: ClockDataPropertiesData -} - -/// ///////// SETTING TRUTH OPTION -export interface TruthOptionDataSourceData extends ISettingTruthOption { - dfid: string - Quest: string -} -export interface TruthOptionDataPropertiesData - extends TruthOptionDataSourceData {} -export interface TruthOptionDataSource { - system: TruthOptionDataSourceData - type: 'truth' -} -export interface TruthOptionDataProperties { - system: TruthOptionDataPropertiesData - type: 'truth' -} +import type { + ProgressTrackDataProperties, + ProgressTrackDataSource +} from './subtypes/progress' +import type { + TruthOptionDataProperties, + TruthOptionDataSource +} from './subtypes/truth' /// DATA MODEL TYPING export type JournalEntryPageDataSource = | { type: ValueOf; system: object } | ProgressTrackDataSource - | ClockDataSource | TruthOptionDataSource export type JournalEntryPageDataProperties = | { type: ValueOf; system: object } | ProgressTrackDataProperties - | ClockDataProperties | TruthOptionDataProperties declare global { diff --git a/src/module/journal/journal-entry-page.ts b/src/module/journal/journal-entry-page.ts index b93849cb3..abf92b117 100644 --- a/src/module/journal/journal-entry-page.ts +++ b/src/module/journal/journal-entry-page.ts @@ -1,17 +1,4 @@ -import type { DocumentModificationOptions } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/abstract/document.mjs' -import type { RollTableDataConstructorData } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/rollTableData' -import type { TableResultDataConstructorData } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/tableResultData' -import type { BaseUser } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/documents.mjs' -import type { IRow } from 'dataforged' -import { clamp } from 'lodash-es' -import { RANK_INCREMENTS } from '../constants' import { typedDeleteDialog } from '../helpers/util' -import { OracleTable } from '../roll-table/oracle-table' -import { OracleTableResult } from '../roll-table/oracle-table-result' -import type { - ProgressTrackDataPropertiesData, - TruthOptionDataPropertiesData -} from './journal-entry-page-types' /** * Extends the base {@link JournalEntryPage} document class. @@ -19,83 +6,12 @@ import type { export class IronswornJournalPage< T extends JournalEntryPageType = JournalEntryPageType > extends JournalEntryPage { - protected override async _preCreate( - data: JournalEntryPageData.ConstructorData, - options: DocumentModificationOptions, - user: BaseUser - ): Promise { - // FIXME: JEPs aren't initialized with proper defaults, so we DIY it. - // https://github.com/foundryvtt/foundryvtt/issues/8628 - const defaults = game.system.template.JournalEntryPage?.[ - data.type - ] as JournalEntryPageDataSource - if (defaults != null) { - const alreadySet = data.system - const newSourceData = mergeObject(defaults, alreadySet ?? {}, { - recursive: true - }) - // @ts-expect-error - this.updateSource({ system: newSourceData }) - } - await super._preCreate(data, options, user) - } - - toTruthTableResultData() { - if (this.type !== 'truth') return undefined - const system = this.system as TruthOptionDataPropertiesData - const data: TableResultDataConstructorData = { - range: [system.Floor ?? 0, system.Ceiling ?? 100], - text: system.Result, - flags: { - 'foundry-ironsworn': { - sourceId: this.uuid, - type: 'truth-option' - } - } - } - return data - } - - // SETTING TRUTH OPTION METHODS - get subtable() { - if (this.type !== 'truth') return undefined - const pageSystem = this.system as TruthOptionDataPropertiesData - if (pageSystem.Subtable?.length == null) return undefined - return new OracleTable({ - name: this.name ?? '???', - formula: '1d100', - results: pageSystem.Subtable.map((row) => - OracleTableResult.getConstructorData( - row as IRow & { Floor: number; Ceiling: number } - ) - ), - flags: { - 'foundry-ironsworn': { - subtitle: game.i18n.localize('IRONSWORN.First Start.SettingTruths'), - sourceId: this.uuid, - type: 'truth-option-subtable' - } - } - }) - } - - // PROGRESS METHODS - /** - * Mark progress on a progress track. - * @param progressUnits The number of times that progress is to be marked. - */ - async markProgress(progressUnits = 1) { - if (this.type !== 'progress') return - const system = this.system as ProgressTrackDataPropertiesData - const oldTicks = system.ticks ?? 0 - const minTicks = 0 - const maxTicks = 40 - const increment = RANK_INCREMENTS[system.rank] * progressUnits - const newValue = clamp(oldTicks + increment, minTicks, maxTicks) - return await this.update({ 'system.ticks': newValue }) + override async deleteDialog(options: Partial = {}) { + return await typedDeleteDialog(this, options) } - override async deleteDialog(options?: Partial) { - return await typedDeleteDialog(this, options) + static override migrateData(data: any) { + if (data.type === 'progress') data.type = 'progressTrack' + return super.migrateData(data) } } diff --git a/src/module/journal/journal-entry.ts b/src/module/journal/journal-entry.ts index 9f68e47f5..1bdfb61f4 100644 --- a/src/module/journal/journal-entry.ts +++ b/src/module/journal/journal-entry.ts @@ -5,7 +5,7 @@ export class IronswornJournalEntry extends JournalEntry { get truthTable() { if (this.pageTypes.truth.length === 0) return undefined const results = this.pageTypes.truth.map((truth) => - truth.toTruthTableResultData() + truth.system.toTruthTableResultData() ) return new OracleTable({ name: this.name ?? '', diff --git a/src/module/journal/sheet/progress-page.ts b/src/module/journal/sheet/progress-page.ts index 2182165fc..5dc6dd47a 100644 --- a/src/module/journal/sheet/progress-page.ts +++ b/src/module/journal/sheet/progress-page.ts @@ -1,9 +1,10 @@ -import { fill, range } from 'lodash-es' -import { RANK_INCREMENTS } from '../../constants' import { ChallengeRank } from '../../fields/ChallengeRank' -import { IronswornPrerollDialog } from '../../rolls' +import type { ProgressTrackSource } from '../../model/ProgressTrack' +import type { IronswornJournalPage } from '../journal-entry-page' export class JournalProgressPageSheet extends JournalPageSheet { + declare object: IronswornJournalPage<'progressTrack'> + static get defaultOptions() { const options = super.defaultOptions options.height = 300 @@ -31,45 +32,40 @@ export class JournalProgressPageSheet extends JournalPageSheet { } getData(options?: Partial | undefined): any { - const data = super.getData(options) as any + const data = super.getData(options) as any as JournalPageSheet.Data & { + document: IronswornJournalPage<'progressTrack'> + data: { system: ProgressTrackSource } + currentRank: string + rankButtons: Array<{ rank: number; i18nRank: string; selected: boolean }> + filledBoxes: number + boxes: Array<{ + ticks: number + lineTransforms: string[] + }> + } + + data.filledBoxes = data.document.system.filledBoxes + + data.currentRank = data.document.system.localizeRank() - data.currentRank = ChallengeRank.localizeValue( - data.data.system.rank ?? ChallengeRank.RANK.Troublesome - ) data.rankButtons = Object.values(ChallengeRank.RANK).map((rank) => ({ rank, i18nRank: ChallengeRank.localizeValue(rank), selected: data.data.system.rank === rank })) - // Compute some progress numbers - const boxes = range(10).map((_) => ({ - ticks: 0, - lineTransforms: [] as string[] - })) - const ticksRemainder = data.data.system.ticks % 4 - data.filledBoxes = Math.floor(data.data.system.ticks / 4) - - fill(boxes, { ticks: 4, lineTransforms: [] }, 0, data.filledBoxes) - boxes[data.filledBoxes] = { ticks: ticksRemainder, lineTransforms: [] } - - // List of line transforms + // SVG line transforms for each tick const transforms = [ - 'rotate(-45, 50, 50)', - 'rotate(45, 50, 50)', - 'rotate(-90, 50, 50)', - '' + 'rotate(-45, 50, 50)', // tick 1 + 'rotate(45, 50, 50)', // tick 2 + 'rotate(-90, 50, 50)', // tick 3 + '' // tick 4 ] - for (let i = 0; i < boxes.length; i++) { - const box = boxes[i] - - if (box.ticks > 0) box.lineTransforms.push(transforms[0]) - if (box.ticks > 1) box.lineTransforms.push(transforms[1]) - if (box.ticks > 2) box.lineTransforms.push(transforms[2]) - if (box.ticks > 3) box.lineTransforms.push(transforms[3]) - } - data.boxes = boxes + data.boxes = data.document.system.boxValues.map((ticks) => ({ + ticks, + lineTransforms: transforms.slice(0, ticks) + })) return data } @@ -82,30 +78,15 @@ export class JournalProgressPageSheet extends JournalPageSheet { this.render() }) html.find('.ironsworn__progress__mark').on('click', async () => { - await increment(this.object, 1) + await this.object.system.mark(1) this.render() }) html.find('.ironsworn__progress__unmark').on('click', async () => { - await increment(this.object, -1) + await this.object.system.mark(-1) this.render() }) - html.find('.ironsworn__progress__roll').on('click', async () => { - const { filledBoxes } = await this.getData() - IronswornPrerollDialog.showForProgress( - this.object.name ?? '(progress)', - filledBoxes - ) - }) + html + .find('.ironsworn__progress__roll') + .on('click', async () => this.object.system.roll()) } } - -function increment(object: any, direction: 1 | -1) { - const rank: ChallengeRank.Value = - object.system.rank ?? ChallengeRank.RANK.Troublesome - const increment = RANK_INCREMENTS[rank] - const currentValue = object.system.ticks || 0 - const newValue = currentValue + increment * direction - return object.update({ - system: { ticks: Math.min(Math.max(newValue, 0), 40) } - }) -} diff --git a/src/module/journal/subtypes/progress.ts b/src/module/journal/subtypes/progress.ts new file mode 100644 index 000000000..fbfdbf28a --- /dev/null +++ b/src/module/journal/subtypes/progress.ts @@ -0,0 +1,48 @@ +import type { IronswornActor } from '../../actor/actor' +import type { ProgressTrackSource } from '../../model/ProgressTrack' +import { ProgressTrack } from '../../model/ProgressTrack' +import type { IronswornJournalPage } from '../journal-entry-page' + +/** Model for journal entry pages of the `progress` subtype. Represents a single progress track. */ +export class ProgressTrackModel extends ProgressTrack< + IronswornJournalPage<'progressTrack'> & + foundry.abstract.Document +> { + /** + * Mark progress on this track, incrementing `ticks` according to the track's challenge rank. + * @param times The number of times that progress is to be marked. Negative values may be used to erase progress. (default: `1`) + */ + async mark(times = 1) { + return await this.parent.update({ + system: this.getMarkData(times) + }) + } + + override async roll({ + actor, + moveDfid + }: { + actor?: IronswornActor + moveDfid?: string + } = {}) { + return await super.roll({ + actor, + moveDfid, + objective: this.parent.name as string + }) + } +} +export interface ProgressTrackModel + extends ProgressTrack< + IronswornJournalPage<'progressTrack'> & + foundry.abstract.Document + > {} + +export interface ProgressTrackDataProperties { + type: 'progressTrack' + system: ProgressTrackModel +} +export interface ProgressTrackDataSource { + type: 'progressTrack' + system: ProgressTrackSource +} diff --git a/src/module/journal/subtypes/truth.ts b/src/module/journal/subtypes/truth.ts new file mode 100644 index 000000000..2a436183e --- /dev/null +++ b/src/module/journal/subtypes/truth.ts @@ -0,0 +1,91 @@ +import type { TableResultDataConstructorData } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/data.mjs/tableResultData' +import type { IRow, ISettingTruthOption } from 'dataforged' +import { DataforgedIDField } from '../../fields/DataforgedIDField' +import type { DataSchema } from '../../fields/utils' +import { OracleTable } from '../../roll-table/oracle-table' +import { OracleTableResult } from '../../roll-table/oracle-table-result' +import type { IronswornJournalPage } from '../journal-entry-page' + +export class TruthModel extends foundry.abstract.TypeDataModel< + TruthOptionDataSourceData, + TruthOptionDataPropertiesData, + IronswornJournalPage<'truth'> & foundry.abstract.Document +> { + static override defineSchema(): DataSchema< + TruthOptionDataSourceData, + TruthOptionDataPropertiesData + > { + const fields = foundry.data.fields + return { + dfid: new DataforgedIDField(), + Floor: new fields.NumberField({ integer: true }), + Ceiling: new fields.NumberField({ integer: true }), + Summary: new fields.HTMLField(), + Description: new fields.HTMLField(), + Quest: new fields.HTMLField(), + Result: new fields.HTMLField(), + Subtable: new fields.ArrayField( + new fields.SchemaField({ + Floor: new fields.NumberField({ integer: true }), + Ceiling: new fields.NumberField({ integer: true }), + Result: new fields.HTMLField() + }) + ) + } + } + + get subtable() { + if (this.Subtable?.length == null) return undefined + return new OracleTable({ + name: this.parent.name ?? '???', + formula: '1d100', + results: this.Subtable.map((row) => + OracleTableResult.getConstructorData( + row as IRow & { Floor: number; Ceiling: number } + ) + ), + flags: { + 'foundry-ironsworn': { + subtitle: game.i18n.localize('IRONSWORN.First Start.SettingTruths'), + sourceId: this.parent.uuid, + type: 'truth-option-subtable' + } + } + }) + } + + toTruthTableResultData() { + const data: TableResultDataConstructorData = { + range: [this.Floor ?? 0, this.Ceiling ?? 100], + text: this.Result, + flags: { + 'foundry-ironsworn': { + sourceId: this.parent.uuid, + type: 'truth-option' + } + } + } + return data + } +} +export interface TruthModel extends TruthOptionDataPropertiesData {} + +export interface TruthOptionDataPropertiesData + extends TruthOptionDataSourceData {} + +export interface TruthOptionDataSourceData + extends Pick< + ISettingTruthOption, + 'Ceiling' | 'Floor' | 'Subtable' | 'Description' | 'Summary' | 'Result' + > { + dfid: string + Quest: string +} +export interface TruthOptionDataSource { + system: TruthOptionDataSourceData + type: 'truth' +} +export interface TruthOptionDataProperties { + system: TruthModel + type: 'truth' +} diff --git a/src/module/model/Clock.ts b/src/module/model/Clock.ts new file mode 100644 index 000000000..ad10606ba --- /dev/null +++ b/src/module/model/Clock.ts @@ -0,0 +1,43 @@ +import type { DataSchema } from '../fields/utils' + +export class Clock< + Parent extends foundry.abstract.DataModel.AnyOrDoc = foundry.abstract.DataModel.AnyOrDoc +> extends foundry.abstract.DataModel { + static readonly MIN = 0 as const + static readonly MAX = 12 as const + static readonly SIZES = [4, 6, 8, 10, 12] as const + static readonly SIZE_MIN = 4 as const + + static override defineSchema(): DataSchema { + return { + value: new foundry.data.fields.NumberField({ + initial: Clock.MIN, + integer: true, + min: Clock.MIN, + max: Clock.MAX, + label: 'IRONSWORN.SegmentsFilled' + }), + max: new foundry.data.fields.NumberField({ + initial: Clock.SIZE_MIN, + integer: true, + choices: Clock.SIZES as any, + min: Clock.SIZE_MIN, + max: Clock.MAX, + label: 'IRONSWORN.SegmentsMax' + }), + enabled: new foundry.data.fields.BooleanField({ + required: false, + label: 'IRONSWORN.Enabled' + }) + } + } +} +export interface Clock< + Parent extends foundry.abstract.DataModel.AnyOrDoc = foundry.abstract.DataModel.AnyOrDoc +> extends ClockSource {} + +export interface ClockSource { + value: number + max: 4 | 6 | 8 | 10 | 12 + enabled?: boolean +} diff --git a/src/module/model/LegacyTrack.ts b/src/module/model/LegacyTrack.ts new file mode 100644 index 000000000..6678418a9 --- /dev/null +++ b/src/module/model/LegacyTrack.ts @@ -0,0 +1,117 @@ +import type { CharacterModel } from '../actor/config' +import { ChallengeRank } from '../fields/ChallengeRank' +import type { DataSchema } from '../fields/utils' +import type { ProgressLikeSource, ProgressLikeProperties } from './ProgressLike' +import { ProgressLike } from './ProgressLike' + +/** Represents a Starforged legacy track. */ + +export class LegacyTrack extends ProgressLike< + LegacyTrackSource, + LegacyTrackProperties, + CharacterModel +> { + /** The number of ticks marked on the legacy track for completing a progress track of the given rank. */ + static readonly REWARD: Record< + LegacyTrack.RewardRank | keyof (typeof ChallengeRank)['RANK'], + number + > = { + /** Use for Troublesome tracks that have their reward reduced (e.g. from selecting that option after a weak hit from a progress move). */ + 0: 0, + [ChallengeRank.RANK.Troublesome]: 1, + Troublesome: 1, + [ChallengeRank.RANK.Dangerous]: 2, + Dangerous: 2, + [ChallengeRank.RANK.Formidable]: 4, + Formidable: 4, + [ChallengeRank.RANK.Extreme]: 8, + Extreme: 8, + [ChallengeRank.RANK.Epic]: 12, + Epic: 12, + /** Use for Epic tracks that have their reward increased (e.g. from selecting that option after a weak hit from a progress move). */ + 6: 16 + } as const + + static readonly XP_MIN = 0 + static readonly XP_PER_BOX = 2 + static readonly XP_PER_BOX_OVERFLOW = 1 + static readonly TICKS_TO_OVERFLOW = + LegacyTrack.BOXES * LegacyTrack.TICKS_PER_BOX + + getMarkData( + rewardRank: LegacyTrack.RewardRank = ChallengeRank.RANK.Troublesome + ): { ticks: number } { + return { ticks: this.ticks + LegacyTrack.REWARD[rewardRank] } + } + + async roll({ moveDfid }: { moveDfid?: string } = {}): Promise { + const actor = this.parent.parent + return await super.roll({ actor, moveDfid }) + } + + protected _validateModel(data: LegacyTrackSource): void { + super._validateModel(data) + const xpEarned = LegacyTrack.#getXpEarned(data.ticks) + if (data.xpSpent > xpEarned) + throw new Error( + `xpSpent (${data.xpSpent}) exceeds computed xpEarned (${xpEarned})` + ) + } + + get overflowProgress() { + if (this.score <= LegacyTrack.SCORE_MAX) return null + return LegacyTrack.getFilledBoxes(this.ticks) - this.score + } + + get xpEarned() { + return LegacyTrack.#getXpEarned(this.ticks) + } + + static #getXpEarned(ticks: number) { + const fullRateBoxes = this.getScore(ticks) + + const fullRateXp = fullRateBoxes * LegacyTrack.XP_PER_BOX + + if (ticks <= this.TICKS_TO_OVERFLOW) return fullRateXp + + const overflowBoxes = this.getScore(ticks, true) - this.BOXES + + return fullRateXp + overflowBoxes * LegacyTrack.XP_PER_BOX_OVERFLOW + } + + static override defineSchema(): DataSchema< + LegacyTrackSource, + LegacyTrackProperties + > { + const fields = foundry.data.fields + return { + ticks: new fields.NumberField({ + initial: this.TICKS_MIN, + min: this.TICKS_MIN, + integer: true + }), + xpSpent: new fields.NumberField({ + initial: this.XP_MIN, + min: this.XP_MIN, + integer: true + }) + } + } +} +export interface LegacyTrack + extends ProgressLike< + LegacyTrackSource, + LegacyTrackProperties, + CharacterModel + >, + LegacyTrackProperties {} +export interface LegacyTrackProperties + extends ProgressLikeProperties, + LegacyTrackSource {} +export interface LegacyTrackSource extends ProgressLikeSource { + xpSpent: number +} + +export namespace LegacyTrack { + export type RewardRank = 0 | ChallengeRank.Value | 6 +} diff --git a/src/module/model/ProgressLike.ts b/src/module/model/ProgressLike.ts new file mode 100644 index 000000000..7b0d04158 --- /dev/null +++ b/src/module/model/ProgressLike.ts @@ -0,0 +1,138 @@ +import { fill } from 'lodash-es' +import type { IronswornActor } from '../actor/actor' +import { IronswornPrerollDialog } from '../rolls' + +/** Constants and behavior common to progress tracks, Starforged legacy tracks, and the classic Bonds track. */ +export abstract class ProgressLike< + SourceData extends ProgressLikeSource, + ConcreteData extends ProgressLikeProperties, + Parent extends foundry.abstract.DataModel.AnyOrDoc = foundry.abstract.DataModel.AnyOrDoc +> extends foundry.abstract.DataModel { + /** The minimum score when making a progress roll. */ + static readonly SCORE_MIN = 0 as const + /** The maximum score when making a progress roll. */ + static readonly SCORE_MAX = 10 as const + /** The number of ticks in one box of progress. */ + static readonly TICKS_PER_BOX = 4 as const + /** The number of boxes in a progress track. */ + static readonly BOXES = this.SCORE_MAX + /** The minimum number of ticks in a progress track. */ + static readonly TICKS_MIN = 0 as const + /** The maximum number of ticks in a progress track. */ + static readonly TICKS_MAX = this.TICKS_PER_BOX * this.BOXES + + abstract getMarkData(value?: number): { ticks: number } + + // getAlertText(changes: Partial) { + // if (changes.ticks != null) { + // const advanced = changes.ticks > this.ticks + + // return game.i18n.localize( + // `IRONSWORN.ChatAlert.Progress${advanced ? 'Advanced' : 'Reduced'}` + // ) + // } + // return undefined + // } + + /** Make a progress roll against this track. */ + async roll({ + actor, + objective, + moveDfid + }: { + actor?: IronswornActor + objective?: string + moveDfid?: string + }) { + if (moveDfid != null) + return await IronswornPrerollDialog.showForOfficialMove(moveDfid, { + actor, + progress: { + source: objective ?? '', + value: this.score + } + }) + // no progress move available -- fall back to generic progress dialog + return await IronswornPrerollDialog.showForProgress( + objective ?? '(progress)', + this.score, + actor ?? undefined, + moveDfid + ) + } + + /** The derived progress score, an integer from 0 to 10. Capped at SCORE_MAX. */ + get score() { + return ProgressLike.getScore(this.ticks) + } + + /** The derived progress score, with remainder ticks represented as a decimal value (`0.25` per tick). Capped at SCORE_MAX. */ + get decimalValue() { + return ProgressLike.getScore(this.ticks, true) + } + + /** The number of filled progress boxes. Unlike `score`, this is *not* capped by SCORE_MAX. */ + get filledBoxes() { + return ProgressLike.getFilledBoxes(this.ticks) + } + + /** Computes the visible ticks in each box of this progress track. + * @return An array of numbers. Each value in the array represents the number of ticks in a progress box. + * */ + get boxValues() { + return ProgressLike.getBoxValues(this.ticks) + } + + /** + * Compute the progress score for the given number of ticks. This is capped at SCORE_MAX. + * @param ticks - The number of progress ticks to compute for. + * @param asDecimal - Should remainder ticks be represented as a decimal value? (default: `false`) + */ + static getScore(ticks: number, asDecimal = false) { + return Math.clamped( + this.getFilledBoxes(ticks, asDecimal), + this.SCORE_MIN, + this.SCORE_MAX + ) + } + + /** + * Compute the number of filled progress boxes. Unlike `getScore`, this is *not* capped by SCORE_MAX. + * @param ticks - The number of progress ticks to compute for. + * @param asDecimal - Should remainder ticks be represented as a decimal value? (default: `false`) + */ + static getFilledBoxes(ticks: number, asDecimal = false) { + if (asDecimal) return ticks / this.TICKS_PER_BOX + return Math.floor(ticks / this.TICKS_PER_BOX) + } + + /** Computes the ticks in each box of a progress track. + * @param ticks - The number of ticks on the progress track. + * @param ignoreScoreMax - Can the result have more than 10 progress boxes? If `false`, only the *last* 10 boxes will be returned. (default: `false`) + * @return An array of numbers. Each value in the array represents the number of ticks in a progress box. + */ + static getBoxValues(ticks: number, ignoreScoreMax = false) { + const tracksFilled = Math.floor(this.getFilledBoxes(ticks) / this.BOXES) + const boxValues = Array(this.BOXES * (1 + tracksFilled)) + const filledBoxes = this.getFilledBoxes(ticks) + const ticksRemainder = ticks % this.TICKS_PER_BOX + + fill(boxValues, this.TICKS_PER_BOX, 0, filledBoxes) + if (ticksRemainder > 0) boxValues[filledBoxes] = ticksRemainder + + if (ignoreScoreMax) return boxValues + + return boxValues.slice(-this.BOXES) + } +} +export interface ProgressLike< + SourceData extends ProgressLikeSource, + ConcreteData extends ProgressLikeProperties, + Parent extends foundry.abstract.DataModel.AnyOrDoc = foundry.abstract.DataModel.AnyOrDoc +> extends foundry.abstract.DataModel, + ProgressLikeProperties {} + +export interface ProgressLikeProperties extends ProgressLikeSource {} +export interface ProgressLikeSource { + ticks: number +} diff --git a/src/module/model/ProgressTrack.ts b/src/module/model/ProgressTrack.ts new file mode 100644 index 000000000..b5dc12ded --- /dev/null +++ b/src/module/model/ProgressTrack.ts @@ -0,0 +1,170 @@ +import { ForeignDocumentField } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/fields.mjs' +import type { IronswornActor } from '../actor/actor' +import { getFoundryMoveByDfId, hashLookup } from '../dataforged' +import { ChallengeRank } from '../fields/ChallengeRank' +import type { DataSchema } from '../fields/utils' +import { IronswornSettings } from '../helpers/settings' +import { IronswornItem } from '../item/item' +import type { ProgressLikeSource, ProgressLikeProperties } from './ProgressLike' +import { ProgressLike } from './ProgressLike' + +/** Represents an Ironsworn progress track. */ +export class ProgressTrack< + Parent extends foundry.abstract.DataModel.AnyOrDoc = foundry.abstract.DataModel.AnyOrDoc +> extends ProgressLike { + // max?: number + // value?: number + + /** The number of ticks per unit of progress (in other words, per instance of "mark progress") for this track's challenge rank. */ + get #unit() { + return ProgressTrack.INCREMENT[this.rank] + } + + /** + * Configure a `Document.update` data object for use in marking this progress track. Use negative `times` to erase progress. + * + * Note that this only creates the data object. You still need to send it with e.g. `Document.update`. + * + * @param times The number of units of progress to be marked (default: `1`). + */ + getMarkData(times = 1) { + return { + ticks: Math.clamped( + this.ticks + this.#unit * times, + ProgressTrack.TICKS_MIN, + ProgressTrack.TICKS_MAX + ) + } + } + + #getDefaultProgressMove(actor?: IronswornActor) { + const isStarforged = + actor?.toolset === 'starforged' ?? + IronswornSettings.starforgedToolsEnabled + + switch (this.subtype) { + case 'vow': + return isStarforged + ? 'Starforged/Moves/Quest/Fulfill_Your_Vow' + : 'Ironsworn/Moves/Quest/Fulfill_Your_Vow' + case 'connection': + if (isStarforged) return 'Starforged/Moves/Connection/Forge_a_Bond' + break + case 'delve': + return 'Ironsworn/Moves/Delve/Locate_Your_Objective' + } + return undefined + } + + /** Make a progress roll against this progress track. */ + async roll({ + actor, + objective, + moveDfid = this.#getDefaultProgressMove(actor) + }: { + actor?: IronswornActor + objective?: string + moveDfid?: string + } = {}) { + return await super.roll({ actor, objective, moveDfid }) + } + + static override migrateData(source) { + // @ts-expect-error + source = super.migrateData(source) + foundry.abstract.Document._addDataFieldMigration(source, 'current', 'ticks') + + // if (source.subtype === 'bond') source.subtype = 'connection' + + if (typeof source.subtype === 'string') { + const subtype = source.subtype as ProgressSubtype | 'bond' + const isStarforged = true + let dfid: string | null = null + switch (subtype) { + case 'vow': + dfid = isStarforged + ? 'Starforged/Moves/Quest/Fulfill_Your_Vow' + : 'Ironsworn/Moves/Quest/Fulfill_Your_Vow' + break + case 'bond': + case 'connection': + dfid = 'Starforged/Moves/Connection/Forge_a_Bond' + break + } + if (dfid != null) { + source.move = hashLookup(dfid) + // fvttt would attempt Item.get(id, {pack: somePack}) + // but how do i get it to initialize with specific options in order to specify the pack in the first place??? + } + } + + return source + } + + /** Provide a localized label for this progress track's challenge rank. */ + localizeRank() { + const field = this.schema.getField('rank') as unknown as ChallengeRank + return game.i18n.localize(field.choices[this.rank]) + } + + static override defineSchema(): DataSchema< + ProgressTrackSource, + ProgressTrackProperties + > { + const fields = foundry.data.fields + return { + ticks: new fields.NumberField({ + initial: this.TICKS_MIN, + min: this.TICKS_MIN, + max: this.TICKS_MAX, + integer: true + }), + enabled: new fields.BooleanField({ initial: true }), + rank: new ChallengeRank(), + // TODO: improve the typing for this + move: new fields.ForeignDocumentField(foundry.documents.BaseItem) + } + } + + /** + * The number of ticks in one unit of progress, for each challenge rank. + */ + static readonly INCREMENT: Record< + ChallengeRank.Value | keyof (typeof ChallengeRank)['RANK'], + number + > = { + [ChallengeRank.RANK.Troublesome]: 12, + Troublesome: 12, + [ChallengeRank.RANK.Dangerous]: 8, + Dangerous: 8, + [ChallengeRank.RANK.Formidable]: 4, + Formidable: 4, + [ChallengeRank.RANK.Extreme]: 2, + Extreme: 2, + [ChallengeRank.RANK.Epic]: 1, + Epic: 1 + } as const +} +export interface ProgressTrack< + Parent extends foundry.abstract.DataModel.AnyOrDoc +> extends foundry.abstract.DataModel< + ProgressTrackSource, + ProgressTrackProperties, + Parent + >, + ProgressTrackProperties {} + +type ProgressSubtype = 'vow' | 'progress' | 'connection' | 'foe' | 'delve' + +export interface ProgressTrackSource extends ProgressLikeSource { + rank: ChallengeRank.Value + subtype: ProgressSubtype + enabled?: boolean + move?: string +} + +export interface ProgressTrackProperties + extends ProgressLikeProperties, + Omit { + move: IronswornItem<'sfmove'> | null +} diff --git a/src/module/roll-table/oracle-table.ts b/src/module/roll-table/oracle-table.ts index c2e7ee8fd..485392f26 100644 --- a/src/module/roll-table/oracle-table.ts +++ b/src/module/roll-table/oracle-table.ts @@ -367,7 +367,7 @@ export class OracleTable extends RollTable { table = (source as IronswornJournalEntry).truthTable break case 'truth-option-subtable': - table = (source as IronswornJournalPage).subtable + table = (source as IronswornJournalPage<'truth'>).system.subtable break default: break diff --git a/src/module/vue/components/buttons/btn-rollprogress.vue b/src/module/vue/components/buttons/btn-rollprogress.vue index 1dada0c16..10c68b344 100644 --- a/src/module/vue/components/buttons/btn-rollprogress.vue +++ b/src/module/vue/components/buttons/btn-rollprogress.vue @@ -1,10 +1,14 @@