UNPKG

@zerospacegg/iolin

Version:

Pure TypeScript implementation of ZeroSpace game data processing (PKL-free)

503 lines 19.4 kB
/** * Entity - Base class for top-level game objects with their own files * Extends Universal with game-specific properties and path-based ID generation */ import { slugify } from "./core.js"; import { Universal } from "./universal.js"; /** * Generate type description for an entity or entity summary * Works with both full Entity instances and lightweight EntitySummary objects * * @param entityOrSummary - Either an Entity instance or EntitySummary object * @returns Formatted type description (e.g., "Grell Co-Op Unit", "Protectorate Hero") */ export function typeDesc(entityOrSummary) { // Handle both Entity instances and EntitySummary objects const type = entityOrSummary.type; const subtype = entityOrSummary.subtype; const faction = entityOrSummary.faction; const tier = entityOrSummary.tier; const id = entityOrSummary.id; if (!type || !subtype) return ""; // Special handling for faction types if (type === "faction") { if (subtype === "main") return "Main Faction"; if (subtype === "mercenary") return "Mercenary Faction"; if (subtype === "nonplayer") return "Non-Player Faction"; return "Faction"; } // Helper function to capitalize words const capitalizeWords = (str) => { return str .split(/[-\s]+/) .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(" "); }; const baseType = capitalizeWords(`${subtype} ${type}`); const withTier = tier && subtype !== "coop-commander" ? `${capitalizeWords(tier)} ${baseType}` : baseType; // Check if this is a co-op entity by looking at the ID const isCoopEntity = id.startsWith("coop/"); // Get faction name - capitalize first letter const factionName = faction ? faction.charAt(0).toUpperCase() + faction.slice(1) : ""; let result = withTier; if (factionName && type !== "faction") { if (isCoopEntity) { result = `${factionName} Co-Op ${withTier}`; } else { result = `${factionName} ${withTier}`; } } return result; } /** * Entity class for top-level game objects that have their own files * Usage: new Entity("Name", (entity) => { entity.description = "..."; }, import.meta.filename) */ export class Entity extends Universal { // Static getters that parse from src static get id() { return this.src.replace(/^src\/zerospace\//, "").replace(/\.ts$/, ""); } static get slug() { const parts = this.src.split("/"); return parts[parts.length - 1].replace(/\.ts$/, ""); } /** * Returns the parent entity's UUID if this is a coop variant, undefined otherwise. * Used for artwork inheritance from PvP to coop variants. */ get parentUUID() { // Extract parent UUID from composite UUID pattern (e.g., "parent-uuid:coop-mera") if (this.uuid.includes(":")) { return this.uuid.replace(/:.*/, ""); } return undefined; } /** * Sets a variant UUID by appending a suffix to the current UUID. * Used by coop entities to create composite UUIDs like "parent-uuid:coop-mera". * * @param variantSuffix - The suffix to append (e.g., "coop-mera") */ setVariantUUID(variantSuffix) { // @ts-ignore - TypeScript can't figure out that super() assigns this.uuid, but it does this.uuid = `${this.uuid}:${variantSuffix}`; } // Ugly accessor methods for private fields _setIdParts(value) { this._idParts = value; } _addTags(...tags) { this._tags.push(...tags); } _removeTags(...tags) { this._tags = this._tags.filter(tag => !tags.includes(tag)); } _removeTagsStartingWith(prefix) { this._tags = this._tags.filter(tag => !tag.startsWith(prefix)); } // Store path-based values (assigned dynamically, no field declarations) // Computed properties using clean getters /** Short display name - defaults to name */ get shortName() { return this._shortName || this.name; } /** Set short display name */ set shortName(value) { this._shortName = value; } constructor(props) { super(); this._tags = []; /** * Dynamic tag predicates - array of [predicate, tag] pairs for computed tags */ this.dynamicTaggers = [ // Built-in dynamic tags [(e) => !!e.type, (e) => e.type], [(e) => !!e.type && !!e.subtype && e.type !== e.subtype, (e) => `${e.type}:${e.subtype}`], [(e) => !!e.faction, (e) => `faction:${e.faction}`], [(e) => !!e.tier && e.tier !== "undefined", (e) => `tier:${e.tier.replace("T", "")}`], [(e) => e.armorType !== "none", (e) => `armor:${e.armorType}`], [(e) => (e.gathersRichFlux ?? 0) > 0 || (e.gathersFlux ?? 0) > 0, "gathers:flux"], [(e) => (e.gathersHexite ?? 0) > 0 || (e.gathersEmptyHexite ?? 0) > 0, "gathers:hexite"], [(e) => e.domain === "air", "flyer"], [(e) => (e.armor ?? 0) > 0, "armored"], [(e) => (e.providesSupply ?? 0) > 0, "provides:supply"], [(e) => (e.providesBiomass ?? 0) > 0, "biomass"], [(e) => e.providesDetection ?? false, "detection"], [(e) => (e.providesUpgradesFor?.length ?? 0) > 0, "provides:upgrades"], [(e) => (e.shields ?? 0) > 0, "shielded"], [(e) => (e.abesEnergy ?? 0) > 0, "abes"], [(e) => (e.energy ?? 0) > 0, "energy"], [(e) => (e.stunResist ?? 0) > 0, "status-resist"], [(e) => e.hasAbilityType("attack"), "attacker"], [(e) => e.hasAbilityType("heal"), "healer"], [(e) => e.hasAbilityType("spell"), "spellcaster"], [(e) => e.hasAbilityType("passive"), "has:passive"], [(e) => !!e.untargetable, "untargetable"], [(e) => e.rebuildable === true, "respawns"], [(e) => e.turrets && Object.keys(e.turrets).length > 0, "has:turret"], [(e) => e.upgrades && Object.keys(e.upgrades).length > 0, "upgradable"], [(e) => e.sacrifices && Object.keys(e.sacrifices).length > 0, "sacrifices"], ]; // Public ability collections with strict typing - direct assignment this.attacks = {}; this.heals = {}; this.spells = {}; this.passives = {}; this.sieges = {}; this.weaponSwitches = {}; this.upgrades = {}; this.turrets = {}; this.sacrifices = {}; this.onDeath = {}; this.tier = ""; this.domain = "ground"; this.hotkey = ""; this.armorType = "none"; // Special properties (mutable - can be modified by upgrades) this.untargetable = false; this.maxAddOns = 0; // Game state (design decisions) this.inGame = true; this.fromFuture = false; this.unlockedBy = []; } /** Auto-generated slug from filename or name */ get slug() { // Use static slug if available (set via static filename) const staticSlug = this.constructor.slug; if (staticSlug) { return staticSlug; } // Use static src to derive slug from filename const staticSrc = this.constructor.src; if (staticSrc) { // Extract filename from path: "src/zerospace/faction/grell/unit/stinger.ts" -> "stinger" const pathWithoutExt = staticSrc.replace(/\.ts$/, ""); const parts = pathWithoutExt.split("/"); return parts[parts.length - 1]; } return slugify(this.name); } /** Auto-generated ID from src path or type/subtype/slug */ get id() { const staticId = this.constructor.id; if (staticId) { if (!this._idParts) { this._idParts = Object.freeze(staticId.split("/")); } return staticId; } let idString; // Derive from static src property const staticSrc = this.constructor.src; if (staticSrc) { idString = staticSrc.replace(/^src\/zerospace\//, "").replace(/\.ts$/, ""); } else if (this.type && this.subtype) { idString = `${this.type}/${this.subtype}/${this.slug}`; } else { idString = this.slug; } // Initialize frozen ID parts if not already done if (!this._idParts) { this._idParts = Object.freeze(idString.split("/")); } return idString; } /** Source file path from static src property */ get src() { return this.constructor.src; } /** ZeroSpace.gg library path */ get zsggPath() { return `https://zerospace.gg/library/${this.id}/`; } /** * Protected getter for manual tags only (to avoid circular dependency) */ get manualTags() { return this._tags; } /** * Generate commander level ID for coop entities * @param level - Commander level number * @returns Commander level ID string * @throws Error if entity is not a coop commander entity */ commanderLevelId(level) { const entityId = this.constructor.id; // Check if this is a coop commander entity const coopMatch = entityId.match(/^coop\/commander\/([^\/]+)\//); if (!coopMatch) { throw new Error(`Entity ${entityId} is not a coop commander entity - cannot generate commander level ID`); } const commanderName = coopMatch[1]; return `coop/commander/${commanderName}-commander/level/${level}`; } /** * Complete tag list combining manual and dynamic tags */ get tags() { return [ ...this._tags, ...this.dynamicTaggers .map(([check, tag]) => (check(this) ? (typeof tag === "function" ? tag(this) : tag) : null)) .filter(Boolean), ]; } /** Pretty formatted type description for display */ get typeDesc() { return typeDesc(this); } /** Get all child entity IDs for ecosystem linking */ get children() { const children = {}; // Add all ability IDs with their navigation paths for (const [key, attack] of Object.entries(this.attacks)) { if (attack.id) children[attack.id] = ["attacks", key]; } for (const [key, heal] of Object.entries(this.heals)) { if (heal.id) children[heal.id] = ["heals", key]; } for (const [key, spell] of Object.entries(this.spells)) { if (spell.id) children[spell.id] = ["spells", key]; } for (const [key, passive] of Object.entries(this.passives)) { if (passive.id) children[passive.id] = ["passives", key]; } for (const [key, onDeath] of Object.entries(this.onDeath)) { if (onDeath.id) children[onDeath.id] = ["onDeath", key]; } for (const [key, siege] of Object.entries(this.sieges)) { if (siege.id) children[siege.id] = ["sieges", key]; } for (const [key, weaponSwitch] of Object.entries(this.weaponSwitches)) { if (weaponSwitch.id) children[weaponSwitch.id] = ["weaponSwitches", key]; } for (const [key, sacrifice] of Object.entries(this.sacrifices)) { if (sacrifice.id) children[sacrifice.id] = ["sacrifices", key]; } for (const [key, turret] of Object.entries(this.turrets)) { if (turret.id) children[turret.id] = ["turrets", key]; } return children; } /** Apply turret transformations and return new instance - chainable! */ withTurrets() { // Create a simple transformed object with current stats // this is completely wrong, wer're not using it yet anywhere so meh. // we eventually want a this.clone() or something to return a new instance we can modify // or we generalize this. idk. const transformed = { ...this, hp: this.hp, armor: this.armor, speed: this.speed, attacks: { ...this.attacks }, heals: { ...this.heals }, spells: { ...this.spells }, passives: { ...this.passives }, onDeath: { ...this.onDeath }, sieges: { ...this.sieges }, weaponSwitches: { ...this.weaponSwitches }, }; // Apply turret transformations using new apply() pattern for (const turret of Object.values(this.turrets)) { if (turret.apply) { turret.apply(); } } return transformed; } /** Apply upgrade transformations and return new instance - chainable! */ withUpgrades(upgradeNames) { // Create a simple transformed object const transformed = { ...this, hp: this.hp, armor: this.armor, speed: this.speed, attacks: { ...this.attacks }, heals: { ...this.heals }, spells: { ...this.spells }, passives: { ...this.passives }, onDeath: { ...this.onDeath }, sieges: { ...this.sieges }, weaponSwitches: { ...this.weaponSwitches }, }; // For now, upgrades work differently than turrets - they're ability stubs // We'll implement this when we have actual upgrade transformations // This is a placeholder for the chainable API return transformed; } /** Create a snapshot of this entity for transformation chaining */ createSnapshot() { // Create a simple object with all current values // this is stupid for the same reason the thing above is stupid // at the very least we should be able to just call .toJSON() :p return { ...this, attacks: { ...this.attacks }, heals: { ...this.heals }, spells: { ...this.spells }, passives: { ...this.passives }, onDeath: { ...this.onDeath }, sieges: { ...this.sieges }, weaponSwitches: { ...this.weaponSwitches }, turrets: { ...this.turrets }, sacrifices: { ...this.sacrifices }, }; } /** * Add tags to this entity. * @param tags - Tags to add to the entity */ tag(...tags) { this._addTags(...tags); } // Lore getter/setter with text normalization get lore() { return this._lore; } set lore(value) { this._lore = this.normalizeText(value); } // Description getter/setter with text normalization get description() { return this._description; } set description(value) { this._description = this.normalizeText(value); } /** Check if entity has ability of specific type */ hasAbilityType(type) { for (const attack of Object.values(this.attacks)) { if (attack.abilityType === type) return true; } for (const heal of Object.values(this.heals)) { if (heal.abilityType === type) return true; } for (const spell of Object.values(this.spells)) { if (spell.abilityType === type) return true; } for (const passive of Object.values(this.passives)) { if (passive.abilityType === type) return true; } for (const onDeath of Object.values(this.onDeath)) { if (onDeath.abilityType === type) return true; } for (const siege of Object.values(this.sieges)) { if (siege.abilityType === type) return true; } for (const weaponSwitch of Object.values(this.weaponSwitches)) { if (weaponSwitch.abilityType === type) return true; } return false; } /** * JSON.stringify() calls this automatically */ toJSON() { return { id: this.id, slug: this.slug, name: this.name, shortName: this.shortName, description: this.description, lore: this.lore, type: this.type, subtype: this.subtype, faction: this.faction, factionName: this.factionName, tier: this.tier, tags: this.tags, typeDesc: this.typeDesc, zsggPath: this.zsggPath, inGame: this.inGame, fromFuture: this.fromFuture, maxOwned: this.maxOwned, attacks: this.attacks, heals: this.heals, spells: this.spells, passives: this.passives, onDeath: this.onDeath, sieges: this.sieges, weaponSwitches: this.weaponSwitches, turrets: this.turrets, upgrades: this.upgrades, children: this.children, // Game stats uuid: this.uuid, hexiteCost: this.hexiteCost, fluxCost: this.fluxCost, buildCount: this.buildCount, cooldown: this.cooldown, energyCost: this.energyCost, energyCostType: this.energyCostType, buildTime: this.buildTime, rebuildable: this.rebuildable, rebuildTime: this.rebuildTime, domain: this.domain, hotkey: this.hotkey, timedLife: this.timedLife, gathersFlux: this.gathersFlux, gathersRichFlux: this.gathersRichFlux, gathersHexite: this.gathersHexite, gathersEmptyHexite: this.gathersEmptyHexite, supply: this.supply, hp: this.hp, vision: this.vision, speed: this.speed, shields: this.shields, abesEnergy: this.abesEnergy, energy: this.energy, startingEnergy: this.startingEnergy, armor: this.armor, armorType: this.armorType, stunResist: this.stunResist, providesSupply: this.providesSupply, providesBiomass: this.providesBiomass, providesDetection: this.providesDetection, providesUpgradesFor: this.providesUpgradesFor, upgradedBy: this.upgradedBy, untargetable: this.untargetable, carryCapacity: this.carryCapacity, maxAddOns: this.maxAddOns, creates: this.creates, createdBy: this.createdBy, unlocks: this.unlocks, unlockedBy: this.unlockedBy, unlocksMercTier: this.unlocksMercTier, // Internal game engine data NOT exported - accessed directly from TypeScript instances // (internalId, internalPath, internalTags, internalSecondaryTags) // Additional dev data stats NOT exported - accessed directly from TypeScript instances // (turnSpeed, pushability, healthRegen) }; } } //# sourceMappingURL=entity.js.map