diff --git a/src/character.ts b/src/character.ts index 20853c9..341ddc5 100644 --- a/src/character.ts +++ b/src/character.ts @@ -1,8 +1,12 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { readFile, writeFile, readdir } from 'fs/promises'; import { join } from 'path'; +import * as string_decoder from "node:string_decoder"; export interface GameCharacter { + fullName?: string; + playbook?: string; + health: number; armor?: number; unstable?: boolean; @@ -16,14 +20,14 @@ export interface GameCharacter { Tough?: number; Weird?: number; - color: number; defaultFacePath: string; additionalFaces?: GameCharacterFace[]; activeFaceSets?: string[]; moves?: GameCharacterMove[]; - improvementsTaken?: string[]; - improvementsAvailable?: string[]; - improvementsAdvanced?: string[]; + availableMoves?: GameCharacterMove[]; + improvementsTaken?: TakenImprovement[]; + improvementsAvailable?: AvailableImprovement[]; + improvementsAdvanced?: AvailableImprovement[]; } export enum GameAttribute { @@ -47,6 +51,7 @@ export interface GameCharacterMoveBase { name: string; summary?: string; description?: string; + playbook?: string; } export interface GameCharacterPassiveMove extends GameCharacterMoveBase { @@ -64,6 +69,56 @@ export interface GameCharacterRollableMove extends GameCharacterMoveBase { onMiss?: string; } +export interface ImprovementBase { + type: string; +} + +export interface AvailableAttributeBonusImprovement extends ImprovementBase { + type: GameAttribute|'attribute'; + max: number; +} + +export interface TakenAttributeBonusImprovement extends AvailableAttributeBonusImprovement { + type: GameAttribute; + from: number; + to: number; +} + +export interface AvailableMoveImprovement extends ImprovementBase { + type: 'playbookMove'|'otherMove'; +} + +export interface TakenOtherPlaybookMoveImprovement extends AvailableMoveImprovement { + type: 'otherMove'; + playbook: string; + name: string; +} + +export interface TakenSamePlaybookMoveImprovement extends AvailableMoveImprovement { + type: 'playbookMove'; + name: string; +} + +export interface AvailableAdvanceBasicMoveImprovement extends ImprovementBase { + type: 'advance'; +} + +export interface TakenAdvanceBasicMoveImprovement extends AvailableAdvanceBasicMoveImprovement { + names: string[] +} + +export interface LuckImprovement extends ImprovementBase { + type: 'luck' +} + +export interface OtherImprovement extends ImprovementBase { + type: 'other'; + text: string; +} + +export type AvailableImprovement = AvailableMoveImprovement|AvailableAdvanceBasicMoveImprovement|AvailableAttributeBonusImprovement|LuckImprovement|OtherImprovement +export type TakenImprovement = TakenSamePlaybookMoveImprovement|TakenOtherPlaybookMoveImprovement|TakenAdvanceBasicMoveImprovement|TakenAttributeBonusImprovement|LuckImprovement|OtherImprovement + export type GameCharacterMove = GameCharacterPassiveMove | GameCharacterRollableMove; export interface FaceConditionBase { diff --git a/src/commands/base.ts b/src/commands/base.ts index 6198fd4..a520939 100644 --- a/src/commands/base.ts +++ b/src/commands/base.ts @@ -22,14 +22,28 @@ import { readFile } from 'fs/promises'; import { join } from 'path'; import { default as Sharp } from 'sharp'; -export const CharacterOptionTemplate = { +export enum CharacterOptionCount { + Single = "single", + Multi = "multi", +} + +export const SingleCharacterOptionTemplate = { name: 'character', + description: 'The character to operate on.', + required: false, + type: CommandOptionType.STRING, + autocomplete: true, + isCharacterOption: CharacterOptionCount.Single, +} as const satisfies ApplicationCommandOptionAutocompletable & { isCharacterOption: CharacterOptionCount.Single }; + +export const MultiCharacterOptionTemplate = { + name: 'characters', description: 'The character(s) to operate on.', required: false, type: CommandOptionType.STRING, autocomplete: true, - isCharacterOption: true -} as const satisfies ApplicationCommandOptionAutocompletable & { isCharacterOption: true }; + isCharacterOption: CharacterOptionCount.Multi, +} as const satisfies ApplicationCommandOptionAutocompletable & { isCharacterOption: CharacterOptionCount.Multi }; export interface CharacterDataBase { readonly success: boolean; @@ -70,7 +84,7 @@ const NORMAL_STATUS_WIDTH = 1446; const enableStackedForTwoCharacters = false; export abstract class AbstractCharacterStatusCommand extends SlashCommand { - readonly characterOptions: (ApplicationCommandOption & { isCharacterOption: true })[]; + readonly characterOptions: (ApplicationCommandOption & { isCharacterOption: CharacterOptionCount })[]; readonly dataDir: string; protected constructor(creator: SlashCreator, opts: SlashCommandOptions & CharacterStatusOptions) { @@ -78,8 +92,8 @@ export abstract class AbstractCharacterStatusCommand extends SlashCommand { this.dataDir = opts.dataDir; this.characterOptions = (opts.options?.filter( - (s) => 'isCharacterOption' in s && s.isCharacterOption === true && s.type === CommandOptionType.STRING - ) ?? []) as (ApplicationCommandOption & { isCharacterOption: true })[]; + (s) => 'isCharacterOption' in s && (s.isCharacterOption === CharacterOptionCount.Multi || s.isCharacterOption === CharacterOptionCount.Single) && s.type === CommandOptionType.STRING + ) ?? []) as (ApplicationCommandOption & { isCharacterOption: CharacterOptionCount })[]; } async autocomplete(ctx: AutocompleteContext): Promise { @@ -87,10 +101,11 @@ export abstract class AbstractCharacterStatusCommand extends SlashCommand { if (!option) { return ctx.sendResults([]); } + const isMulti = option.isCharacterOption === CharacterOptionCount.Multi const party = await loadParty(this.dataDir); const defaultCharacter = party.defaultCharacters[ctx.user.id] || null; const activeParty = new Set(Array.isArray(party.activeParty) ? party.activeParty : []); - const completedNames = (ctx.options[option.name] as string).trimStart().split(nameDelimiter); + const completedNames = isMulti ? (ctx.options[option.name] as string).trimStart().split(nameDelimiter) : [ctx.options[option.name]]; const completingName = completedNames.pop()!; const completingLowercase = completingName.toLowerCase(); const characters = (await listCharacters(this.dataDir)) @@ -139,7 +154,7 @@ export abstract class AbstractCharacterStatusCommand extends SlashCommand { const selectedElements = new Set(); completedNames.forEach((entry) => { const trimmed = entry.trim(); - if (trimmed === '*') { + if (trimmed === '*' && isMulti) { for (const partyMember of activeParty) { selectedElements.add(partyMember); } @@ -154,7 +169,7 @@ export abstract class AbstractCharacterStatusCommand extends SlashCommand { return ctx.sendResults( [ ...characters.map((s) => selectionPrefixValue + s).slice(0, 20), - ...(unselectedParty.size > 0 && activeParty.size > 1 && (partyName.includes(completingName) || completingName.trim() === '*') + ...(isMulti && unselectedParty.size > 0 && activeParty.size > 1 && (partyName.includes(completingName) || completingName.trim() === '*') ? [selectionPrefixValue + partyName] : []) ] @@ -168,33 +183,31 @@ export abstract class AbstractCharacterStatusCommand extends SlashCommand { partyPromise: Promise ): Promise<[option: string, names: Set][]> { const [party, allCharacters] = await Promise.all([partyPromise, listCharacters(this.dataDir)]); + function correctName(item: string): string { + if (allCharacters.includes(item)) { + return item; + } else { + const found = allCharacters.find((v) => item.toLowerCase() === v.toLowerCase()); + if (found) { + return found; + } else { + return item; + } + } + } const activeParty = new Set(Array.isArray(party.activeParty) ? party.activeParty : []); - return this.characterOptions.map((o) => - options[o.name] && typeof options[o.name] === 'string' - ? [ - o.name, - new Set( - (options[o.name] as string) - .trim() - .split(nameDelimiter) - .flatMap((item) => { - if (item === '*') { - return Array.from(activeParty); - } else if (allCharacters.includes(item)) { - return [item]; - } else { - const found = allCharacters.find((v) => item.toLowerCase() === v.toLowerCase()); - if (found) { - return [found]; - } else { - return [item]; - } - } - }) - ) - ] - : [o.name, new Set()] - ); + return this.characterOptions.map((o) => { + if (!options[o.name] || typeof options[o.name] !== 'string') { + return [o.name, new Set()] + } + const isMulti = o.isCharacterOption === CharacterOptionCount.Multi + const trimmed = (options[o.name] as string).trim() + if (!isMulti) { + return [o.name, new Set([correctName(trimmed)])] + } + return [o.name, new Set( + trimmed.split(nameDelimiter).flatMap(item => item === '*' ? Array.from(activeParty) : [correctName(item)]))] + }); } abstract process( diff --git a/src/commands/experience.ts b/src/commands/experience.ts index 06ba0b0..bcd2a88 100644 --- a/src/commands/experience.ts +++ b/src/commands/experience.ts @@ -1,7 +1,7 @@ import { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create'; import { AbstractCharacterStatusCommand, - CharacterOptionTemplate, + MultiCharacterOptionTemplate, type CharacterStatusOptions, type GameCharacterData, type LoadedCharacterData @@ -17,8 +17,8 @@ export class ExperienceCharacterCommand extends AbstractCharacterStatusCommand { guildIDs: process.env.DEVELOPMENT_GUILD_ID, options: [ { - ...CharacterOptionTemplate, - name: 'character', + ...MultiCharacterOptionTemplate, + name: 'characters', description: 'The name of the character(s) to grant EXP to or remove EXP from.', required: true }, @@ -47,7 +47,7 @@ export class ExperienceCharacterCommand extends AbstractCharacterStatusCommand { const delta = ctx.options['delta'] ?? 1; const description: string[] = []; const result: LoadedCharacterData[] = []; - for (const character of characters.get('character')!) { + for (const character of characters.get('characters')!) { if (!character.success) { description.push( `**${character.name}** ${delta >= 0 ? 'gained' : 'lost'} ${Math.abs(delta)} EXP${delta >= 0 ? '!' : '.'}` diff --git a/src/commands/harm.ts b/src/commands/harm.ts index 702250b..d61bd3f 100644 --- a/src/commands/harm.ts +++ b/src/commands/harm.ts @@ -1,7 +1,7 @@ import { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create'; import { AbstractCharacterStatusCommand, - CharacterOptionTemplate, + MultiCharacterOptionTemplate, type CharacterStatusOptions, type GameCharacterData, type LoadedCharacterData @@ -17,7 +17,8 @@ export class HarmCharacterCommand extends AbstractCharacterStatusCommand { guildIDs: process.env.DEVELOPMENT_GUILD_ID, options: [ { - ...CharacterOptionTemplate, + ...MultiCharacterOptionTemplate, + name: 'characters', description: 'The name of the character(s) to harm.', required: true }, @@ -59,7 +60,7 @@ export class HarmCharacterCommand extends AbstractCharacterStatusCommand { const makeUnstable: boolean | null = ctx.options['destabilize'] ?? null; const description: string[] = []; const result: LoadedCharacterData[] = []; - for (const character of characters.get('character')!) { + for (const character of characters.get('characters')!) { if (!character.success) { description.push( `**${character.name}** took ${baseDamage} Harm${piercing ? ' ignore-armour' : ''}${ diff --git a/src/commands/heal.ts b/src/commands/heal.ts index 2899660..a0fa07f 100644 --- a/src/commands/heal.ts +++ b/src/commands/heal.ts @@ -1,7 +1,7 @@ import { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create'; import { AbstractCharacterStatusCommand, - CharacterOptionTemplate, + MultiCharacterOptionTemplate, type CharacterStatusOptions, type GameCharacterData, type LoadedCharacterData @@ -17,8 +17,8 @@ export class HealCharacterCommand extends AbstractCharacterStatusCommand { guildIDs: process.env.DEVELOPMENT_GUILD_ID, options: [ { - ...CharacterOptionTemplate, - name: 'character', + ...MultiCharacterOptionTemplate, + name: 'characters', description: 'The name of the character(s) to heal.', required: true }, @@ -53,7 +53,7 @@ export class HealCharacterCommand extends AbstractCharacterStatusCommand { const stabilize: boolean = ctx.options['stabilize'] ?? false; const description: string[] = []; const result: LoadedCharacterData[] = []; - for (const character of characters.get('character')!) { + for (const character of characters.get('characters')!) { if (!character.success) { description.push( `**${character.name}** healed ${healing} Harm${healing > 0 ? '!' : '.'}${ diff --git a/src/commands/levelup.ts b/src/commands/levelup.ts new file mode 100644 index 0000000..a0716ee --- /dev/null +++ b/src/commands/levelup.ts @@ -0,0 +1,43 @@ +import { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create'; +import { + AbstractCharacterStatusCommand, + type CharacterStatusOptions, + type GameCharacterData, + type LoadedCharacterData, SingleCharacterOptionTemplate +} from './base.js'; + +export class LevelUpCharacterCommand extends AbstractCharacterStatusCommand { + constructor(creator: SlashCreator, opts: CharacterStatusOptions) { + super(creator, { + ...opts, + name: 'levelup', + description: 'Levels up the given character.', + type: ApplicationCommandType.CHAT_INPUT, + guildIDs: process.env.DEVELOPMENT_GUILD_ID, + options: [ + { + ...SingleCharacterOptionTemplate, + name: 'character', + description: 'The name of the character to level up. This character must exist.', + required: true + }, + ] + }); + } + + async process( + ctx: CommandContext, + { characters }: { characters: Map } + ): Promise< + | readonly [string, LoadedCharacterData[]] + | readonly [string, LoadedCharacterData] + | readonly LoadedCharacterData[] + | LoadedCharacterData + | string + > { + const characterList = characters.get("character") + if (!characterList || characterList.length === 0) { + return + } + } +} diff --git a/src/commands/luck.ts b/src/commands/luck.ts index 2c92308..93166ec 100644 --- a/src/commands/luck.ts +++ b/src/commands/luck.ts @@ -1,7 +1,7 @@ import { ApplicationCommandType, type CommandContext, CommandOptionType, type SlashCreator } from 'slash-create'; import { AbstractCharacterStatusCommand, - CharacterOptionTemplate, + MultiCharacterOptionTemplate, type CharacterStatusOptions, type GameCharacterData, type LoadedCharacterData @@ -17,8 +17,8 @@ export class LuckCharacterCommand extends AbstractCharacterStatusCommand { guildIDs: process.env.DEVELOPMENT_GUILD_ID, options: [ { - ...CharacterOptionTemplate, - name: 'character', + ...MultiCharacterOptionTemplate, + name: 'characters', description: 'The name of the character(s) to grant luck to or remove luck from.', required: true }, @@ -47,7 +47,7 @@ export class LuckCharacterCommand extends AbstractCharacterStatusCommand { const delta: number = ctx.options['delta'] ?? -1; const description: string[] = []; const result: LoadedCharacterData[] = []; - for (const character of characters.get('character')!) { + for (const character of characters.get('characters')!) { if (!character.success) { description.push( `**${character.name}** ${delta > 0 ? 'recovered' : 'spent'} ${Math.abs(delta)} Luck${delta !== 0 ? '!' : '.'}` diff --git a/src/commands/party.ts b/src/commands/party.ts index 81310a9..e1f49e9 100644 --- a/src/commands/party.ts +++ b/src/commands/party.ts @@ -1,6 +1,6 @@ import { AbstractCharacterStatusCommand, - CharacterOptionTemplate, + MultiCharacterOptionTemplate, type CharacterStatusOptions, type GameCharacterData, type LoadedCharacterData @@ -18,7 +18,7 @@ export class PartyCommand extends AbstractCharacterStatusCommand { guildIDs: process.env.DEVELOPMENT_GUILD_ID, options: [ { - ...CharacterOptionTemplate, + ...MultiCharacterOptionTemplate, name: 'characters', description: 'The name of the character(s) to become the current party. If not set, shows the party instead.', required: false diff --git a/src/commands/status.ts b/src/commands/status.ts index 7f6cbf4..307049c 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,6 +1,6 @@ import { ApplicationCommandType, type CommandContext, type SlashCreator } from 'slash-create'; import { - CharacterOptionTemplate, + MultiCharacterOptionTemplate, AbstractCharacterStatusCommand, type GameCharacterData, type LoadedCharacterData, @@ -17,8 +17,8 @@ export class CharacterStatusCommand extends AbstractCharacterStatusCommand { guildIDs: process.env.DEVELOPMENT_GUILD_ID, options: [ { - ...CharacterOptionTemplate, - name: 'character', + ...MultiCharacterOptionTemplate, + name: 'characters', description: 'The name of the character(s) to get the status of.', required: true } @@ -36,6 +36,6 @@ export class CharacterStatusCommand extends AbstractCharacterStatusCommand { | LoadedCharacterData | string > { - return characters.get('character')!.flatMap((x) => (x.success ? [x] : [])); + return characters.get('characters')!.flatMap((x) => (x.success ? [x] : [])); } }