scum-quest-library
Version:
S.C.U.M. Quest Library
603 lines (592 loc) • 19.3 kB
JavaScript
import { z } from 'zod';
const BaseConditionSchema = z.object({
CanBeAutoCompleted: z.boolean().default(false),
TrackingCaption: z.string(),
SequenceIndex: z.number().int().min(0),
LocationsShownOnMap: z
.array(z.object({
Location: z.union([
z.object({
X: z.number(),
Y: z.number(),
Z: z.number(),
}),
z.string(),
]),
SizeFactor: z.number().positive().default(1.0),
}))
.optional(),
});
const EliminationConditionsSchema = BaseConditionSchema.extend({
Type: z.literal('Elimination'),
TargetCharacters: z.array(z.string()).min(1),
Amount: z.number().int().min(1),
AllowedWeapons: z.array(z.string()).optional(),
});
const CookLevel = z.enum([
'Raw',
'Undercooked',
'Cooked',
'Overcooked',
'Burned',
]);
const CookQuality = z.enum([
'Ruined',
'Bad',
'Poor',
'Good',
'Excellent',
'Perfect',
]);
const ItemRequirementSchema = z.object({
AcceptedItems: z.array(z.string()).min(1),
RequiredNum: z.number().int().min(1),
RandomAdditionalRequiredNum: z.number().int().min(0).optional(),
MinAcceptedItemUses: z.number().int().min(0).optional(),
MinAcceptedCookLevel: CookLevel.optional(),
MaxAcceptedCookLevel: CookLevel.optional(),
MinAcceptedCookQuality: CookQuality.optional(),
MinAcceptedItemMass: z.number().int().min(0).optional(),
MinAcceptedItemHealth: z.number().int().min(0).max(100).optional(),
MinAcceptedItemResourceRatio: z.number().int().min(0).max(100).optional(),
MinAcceptedItemResourceAmount: z.number().int().min(0).optional(),
});
const FetchConditionSchema = BaseConditionSchema.extend({
Type: z.literal('Fetch'),
DisablePurchaseOfRequiredItems: z.boolean().default(false),
PlayerKeepsItems: z.boolean().default(false),
RequiredItems: z.array(ItemRequirementSchema).min(1),
});
const InteractionLocationSchema = z.object({
AnchorMesh: z.string().min(1),
Instance: z.number().int().optional(),
FallbackTransform: z.string().optional(),
VisibleMesh: z.string().optional(),
});
const InteractionConditionSchema = BaseConditionSchema.extend({
Type: z.literal('Interaction'),
Locations: z.array(InteractionLocationSchema).min(1),
MinNeeded: z.number().int().min(1),
MaxNeeded: z.number().int().min(1),
SpawnOnlyNeeded: z.boolean().default(true),
WorldMarkersShowDistance: z.number().int().min(0).default(50),
});
const ConditionSchema = z
.discriminatedUnion('Type', [
EliminationConditionsSchema,
FetchConditionSchema,
InteractionConditionSchema,
])
.refine(condition => {
if (condition.Type === 'Interaction') {
return condition.MinNeeded <= condition.MaxNeeded;
}
return true;
}, { message: 'MinNeeded must be less than or equal to MaxNeeded' })
.refine(condition => {
if (condition.Type === 'Interaction') {
return condition.MaxNeeded <= condition.Locations.length;
}
return true;
}, {
message: 'MaxNeeded must be less than or equal to the number of locations',
});
// Condition type constants
const CONDITION_TYPES = ['Fetch', 'Elimination', 'Interaction'];
// Helper functions for condition conversion
const extractConditionItems = (condition) => {
if (condition.Type === 'Fetch') {
return condition.RequiredItems.flatMap(item => item.AcceptedItems);
}
if (condition.Type === 'Elimination') {
return condition.TargetCharacters;
}
return [];
};
const extractInteractionObjects = (condition) => {
if (condition.Type === 'Interaction') {
return condition.Locations.map(location => location.AnchorMesh);
}
return [];
};
const getConditionType = (condition) => {
return condition.Type;
};
const isFetchCondition = (condition) => {
return condition.Type === 'Fetch';
};
const isEliminationCondition = (condition) => {
return condition.Type === 'Elimination';
};
const isInteractionCondition = (condition) => {
return condition.Type === 'Interaction';
};
const NPCSchema = z.enum([
'Armorer',
'Banker',
'Barber',
'Bartender',
'Doctor',
'Fischerman',
'GeneralGoods',
'Mechanic',
]);
const SkillSchema = z.enum([
'Archery',
'Aviation',
'Awareness',
'Boxing',
'Camouflage',
'Cooking',
'Demolition',
'Diving',
'Endurance',
'Engineering',
'Farming',
'Handgun',
'Medical',
'MeleeWeapons',
'Motorcycle',
'Rifles',
'Running',
'Sniping',
'Stealth',
'Survival',
'Tactics',
'Thievery',
]);
const SkillRewardSchema = z.object({
Skill: SkillSchema,
Experience: z.number().int().positive(),
});
const TradeDealSchema = z.object({
Item: z.string().min(1),
Price: z.number().int().positive().optional(),
Amount: z.number().int().min(1).optional(),
AllowExcluded: z.boolean().optional(),
Fame: z.number().int().min(0).optional(),
});
const ItemRewardSchema = z.object({
Item: z.string().min(1),
Amount: z.number().int().positive(),
});
const RewardSchema = z
.object({
// Currency Group: 1 Slot
CurrencyNormal: z.number().int().min(0).optional(),
CurrencyGold: z.number().int().min(0).optional(),
Fame: z.number().int().min(0).optional(),
// Skills (each skill counts as 1 slot)
Skills: z.array(SkillRewardSchema).optional(),
// First counts as two slots - additional counts as one
TradeDeals: z.array(TradeDealSchema).min(1).optional(),
})
.refine(reward => {
let slots = 0;
if (reward.CurrencyNormal || reward.CurrencyGold || reward.Fame)
slots += 1;
slots += reward.Skills?.length ?? 0;
if (reward.TradeDeals?.length) {
slots += 2;
if (reward.TradeDeals.length > 1) {
slots += reward.TradeDeals.length - 1;
}
}
return slots <= 5;
}, {
message: 'Total slots for currency, skills, and trade deals must be less than or equal to 5',
});
// import { NPCSchema } from '../common/enums'; // No longer needed
// Quest tier type for external use
const QuestTierSchema = z.union([
z.literal(1),
z.literal(2),
z.literal(3),
]);
const QuestSchema = z
.object({
// Accept both casings for compatibility, normalize to AssociatedNpc
AssociatedNpc: z.string().min(1).optional(),
AssociatedNPC: z.string().min(1).optional(),
Tier: QuestTierSchema,
Title: z.string().min(1),
Description: z.string().min(1),
RewardPool: z.array(RewardSchema).min(1),
Conditions: z.array(ConditionSchema).min(1),
// Required properties
TimeLimitHours: z.number().positive(),
})
.refine(data => !!(data.AssociatedNpc || data.AssociatedNPC), {
message: 'Either AssociatedNpc or AssociatedNPC is required',
})
.transform(data => {
// Normalize to AssociatedNpc, preferring it if both are present
const normalizedNpc = data.AssociatedNpc || data.AssociatedNPC;
return {
...data,
AssociatedNpc: normalizedNpc,
AssociatedNPC: undefined, // Remove the uppercase variant
};
});
const BlockedQuestsSchema = z.object({
BlockAllDefaultQuests: z.boolean().default(false),
BlockQuestNames: z.array(z.string()).default([]),
});
class ConditionBuilder {
constructor() {
this.condition = {};
this.conditionType = null;
}
// Set condition type
asFetch() {
this.conditionType = 'Fetch';
this.condition.Type = 'Fetch';
return this;
}
asElimination() {
this.conditionType = 'Elimination';
this.condition.Type = 'Elimination';
return this;
}
asInteraction() {
this.conditionType = 'Interaction';
this.condition.Type = 'Interaction';
return this;
}
// Common properties
withSequenceIndex(index) {
this.condition.SequenceIndex = index;
return this;
}
withCaption(caption) {
this.condition.TrackingCaption = caption;
return this;
}
autoComplete(auto = true) {
this.condition.CanBeAutoCompleted = auto;
return this;
}
disablePurchase(disable = true) {
if (this.conditionType !== 'Fetch') {
throw new Error('disablePurchase() can only be used with Fetch conditions');
}
this.condition.DisablePurchaseOfRequiredItems =
disable;
return this;
}
// Fetch-specific methods
requireItems(items, count) {
if (this.conditionType !== 'Fetch') {
throw new Error('requireItems() can only be used with Fetch conditions');
}
const fetchCondition = this.condition;
if (!fetchCondition.RequiredItems)
fetchCondition.RequiredItems = [];
fetchCondition.RequiredItems.push({
AcceptedItems: items,
RequiredNum: count,
});
return this;
}
// Add this method to the ConditionBuilder class:
requireItemsWithOptions(items, count, options = {}) {
if (this.conditionType !== 'Fetch') {
throw new Error('requireItemsWithOptions() can only be used with Fetch conditions');
}
const fetchCondition = this.condition;
if (!fetchCondition.RequiredItems)
fetchCondition.RequiredItems = [];
const itemReq = {
AcceptedItems: items,
RequiredNum: count,
};
if (options.minHealth !== undefined)
itemReq.MinAcceptedItemHealth = options.minHealth;
if (options.minMass !== undefined)
itemReq.MinAcceptedItemMass = options.minMass;
if (options.minUses !== undefined)
itemReq.MinAcceptedItemUses = options.minUses;
if (options.cookLevel?.min)
itemReq.MinAcceptedCookLevel = options.cookLevel.min;
if (options.cookLevel?.max)
itemReq.MaxAcceptedCookLevel = options.cookLevel.max;
if (options.cookQuality)
itemReq.MinAcceptedCookQuality = options.cookQuality;
if (options.resourceRatio !== undefined)
itemReq.MinAcceptedItemResourceRatio = options.resourceRatio;
if (options.resourceAmount !== undefined)
itemReq.MinAcceptedItemResourceAmount = options.resourceAmount;
fetchCondition.RequiredItems.push(itemReq);
return this;
}
requireItemsWithHealth(items, count, minHealth) {
if (this.conditionType !== 'Fetch') {
throw new Error('requireItemsWithHealth() can only be used with Fetch conditions');
}
const fetchCondition = this.condition;
if (!fetchCondition.RequiredItems)
fetchCondition.RequiredItems = [];
fetchCondition.RequiredItems.push({
AcceptedItems: items,
RequiredNum: count,
MinAcceptedItemHealth: minHealth,
});
return this;
}
addMapLocation(X, Y, Z, sizeFactor = 1.0) {
if (!this.condition.LocationsShownOnMap) {
this.condition.LocationsShownOnMap = [];
}
this.condition.LocationsShownOnMap.push({
Location: { X, Y, Z },
SizeFactor: sizeFactor,
});
return this;
}
keepItems(keep = true) {
if (this.conditionType !== 'Fetch') {
throw new Error('keepItems() can only be used with Fetch conditions');
}
this.condition.PlayerKeepsItems = keep;
return this;
}
// Elimination-specific methods
eliminateTargets(targets, count) {
if (this.conditionType !== 'Elimination') {
throw new Error('eliminateTargets() can only be used with Elimination conditions');
}
const elimCondition = this.condition;
elimCondition.TargetCharacters = targets;
elimCondition.Amount = count;
return this;
}
withWeapons(weapons) {
if (this.conditionType !== 'Elimination') {
throw new Error('withWeapons() can only be used with Elimination conditions');
}
this.condition.AllowedWeapons = weapons;
return this;
}
build() {
if (!this.conditionType) {
throw new Error('Condition type must be set before building');
}
return this.condition;
}
}
class RewardBuilder {
constructor() {
this.reward = {};
}
currency(normal, gold, fame) {
if (normal !== undefined)
this.reward.CurrencyNormal = normal;
if (gold !== undefined)
this.reward.CurrencyGold = gold;
if (fame !== undefined)
this.reward.Fame = fame;
return this;
}
addSkill(skill, experience) {
if (!this.reward.Skills)
this.reward.Skills = [];
this.reward.Skills.push({ Skill: skill, Experience: experience });
return this;
}
addTradeDeal(item, options = {}) {
if (!this.reward.TradeDeals)
this.reward.TradeDeals = [];
this.reward.TradeDeals.push({
Item: item,
...options,
});
return this;
}
build() {
return this.reward;
}
}
class QuestBuilder {
constructor() {
this.quest = {
RewardPool: [],
Conditions: [],
};
}
// Basic quest properties
withNPC(npc) {
this.quest.AssociatedNpc = npc;
return this;
}
withTier(tier) {
this.quest.Tier = tier;
return this;
}
withTitle(title) {
this.quest.Title = title;
return this;
}
withDescription(description) {
this.quest.Description = description;
return this;
}
withTimeLimit(hours) {
this.quest.TimeLimitHours = hours;
return this;
}
// Condition builders
addCondition(builderFn) {
const conditionBuilder = new ConditionBuilder();
const condition = builderFn(conditionBuilder);
this.quest.Conditions.push(condition);
return this;
}
addFetchCondition(builderFn) {
const conditionBuilder = new ConditionBuilder().asFetch();
const finalBuilder = builderFn(conditionBuilder);
this.quest.Conditions.push(finalBuilder.build());
return this;
}
addEliminationCondition(builderFn) {
const conditionBuilder = new ConditionBuilder().asElimination();
const finalBuilder = builderFn(conditionBuilder);
this.quest.Conditions.push(finalBuilder.build());
return this;
}
addInteractionCondition(builderFn) {
const conditionBuilder = new ConditionBuilder().asInteraction();
const finalBuilder = builderFn(conditionBuilder);
this.quest.Conditions.push(finalBuilder.build());
return this;
}
// Reward builders
addReward(builderFn) {
const rewardBuilder = new RewardBuilder();
const finalBuilder = builderFn(rewardBuilder);
this.quest.RewardPool.push(finalBuilder.build());
return this;
}
// Quick reward helpers
addCurrencyReward(normal, gold, fame) {
return this.addReward(builder => builder.currency(normal, gold, fame));
}
// Validation and building
validate() {
const result = QuestSchema.safeParse(this.quest);
if (result.success) {
return { success: true };
}
return {
success: false,
errors: result.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`),
};
}
build() {
const result = QuestSchema.parse(this.quest);
return result;
}
// Get current state for debugging
preview() {
return { ...this.quest };
}
}
// Conversion utilities
const questToFormData = (quest) => {
return {
AssociatedNpc: quest.AssociatedNpc,
AssociatedNPC: undefined, // Will be normalized by schema
Tier: quest.Tier,
Title: quest.Title,
Description: quest.Description,
TimeLimitHours: quest.TimeLimitHours,
RewardPool: quest.RewardPool,
Conditions: quest.Conditions,
};
};
const formDataToQuest = (formData) => {
const result = QuestSchema.safeParse(formData);
if (!result.success) {
throw new Error(`Invalid quest form data: ${result.error.issues.map(i => i.message).join(', ')}`);
}
return result.data;
};
// Helper functions for form validation (using existing schemas)
const validateQuestForm = (data) => {
const result = QuestSchema.safeParse(data);
if (result.success) {
return { success: true };
}
return {
success: false,
errors: result.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`),
};
};
const validateConditionForm = (data) => {
const result = ConditionSchema.safeParse(data);
if (result.success) {
return { success: true };
}
return {
success: false,
errors: result.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`),
};
};
const validateRewardForm = (data) => {
const result = RewardSchema.safeParse(data);
if (result.success) {
return { success: true };
}
return {
success: false,
errors: result.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`),
};
};
// Form-specific helper functions
const getConditionTypeOptions = () => {
return CONDITION_TYPES.map(type => ({ value: type, label: type }));
};
const createEmptyCondition = (type) => {
const baseCondition = {
CanBeAutoCompleted: false,
TrackingCaption: '',
SequenceIndex: 0,
};
switch (type) {
case 'Fetch':
return {
...baseCondition,
Type: 'Fetch',
DisablePurchaseOfRequiredItems: false,
PlayerKeepsItems: false,
RequiredItems: [],
};
case 'Elimination':
return {
...baseCondition,
Type: 'Elimination',
TargetCharacters: [],
Amount: 1,
};
case 'Interaction':
return {
...baseCondition,
Type: 'Interaction',
Locations: [],
MinNeeded: 1,
MaxNeeded: 1,
SpawnOnlyNeeded: true,
WorldMarkersShowDistance: 50,
};
default:
throw new Error(`Unknown condition type: ${type}`);
}
};
const createEmptyReward = () => {
return {
CurrencyNormal: 0,
CurrencyGold: 0,
Fame: 0,
};
};
export { BlockedQuestsSchema, CONDITION_TYPES, ConditionBuilder, ConditionSchema, CookQuality, ItemRewardSchema, NPCSchema, QuestBuilder, QuestSchema, QuestTierSchema, RewardBuilder, RewardSchema, SkillRewardSchema, SkillSchema, TradeDealSchema, createEmptyCondition, createEmptyReward, extractConditionItems, extractInteractionObjects, formDataToQuest, getConditionType, getConditionTypeOptions, isEliminationCondition, isFetchCondition, isInteractionCondition, questToFormData, validateConditionForm, validateQuestForm, validateRewardForm };
//# sourceMappingURL=index.mjs.map