5180 fichiers 38.5 Mo 0 téléchargements 388 vues mis à jour il y a 7 heures ui
nitro-v36-with-renderer-v36 / src / components / wired / views / actions / WiredActionGiveRewardView.tsx
WiredActionGiveRewardView.tsx 19.8 Ko Télécharger le fichier
import { FC, useEffect, useState } from 'react';
import { FaTrash } from 'react-icons/fa';
import { LocalizeText, localizeWithFallback, WiredFurniType } from '../../../../api';
import { Button, Text } from '../../../../common';
import { useWired } from '../../../../hooks';
import { OctaneInput } from '../../../../layout';
import { WiredSourcesSelector } from '../WiredSourcesSelector';
import { slotUserSource } from '../WiredSourceSlots';
import { WiredActionBaseView } from './WiredActionBaseView';
import { Checkbox } from '../../../../common/Checkbox';
import { Radio } from '../../../../common/Radio';
import { Select } from '../../../../common/Select';
import { NativeTextTree } from '../../../../common/native-text/NativeTextTree';

type RewardType = 'badge' | 'credits' | 'pixels' | 'diamonds' | 'points' | 'furni' | 'respect';

export interface RewardEntry {
    rewardType: RewardType;
    rewardValue: string;
    probability: number;
    pointsType: number;
}

const DEFAULT_PROBABILITY = 100;
const DEFAULT_POINTS_TYPE = 5;
/** Official GiveReward: at most 20 rewards, codes of at most 100 characters. */
const MAX_REWARDS = 20;
const MAX_CODE_LENGTH = 100;
const MIN_PRIZE_LIMIT = 1;
const MAX_PRIZE_LIMIT = 1000;

// Official: hard-coded English options, 2 per row.
const REWARD_INTERVALS: { value: number; label: string }[] = [
    { value: 0, label: 'Once' },
    { value: 1, label: '1 / n Days' },
    { value: 2, label: '1 / n Hours' },
    { value: 3, label: '1 / n Mins' }
];

const clampPrizeLimit = (value: number) => (Number.isFinite(value) ? Math.max(MIN_PRIZE_LIMIT, Math.min(MAX_PRIZE_LIMIT, Math.floor(value))) : MIN_PRIZE_LIMIT);

const REWARD_TYPES: { value: RewardType; key: string; label: string }[] = [
    { value: 'badge', key: 'wiredfurni.params.reward.type.badge', label: 'Badge' },
    { value: 'credits', key: 'wiredfurni.params.reward.type.credits', label: 'Credits' },
    { value: 'pixels', key: 'wiredfurni.params.reward.type.pixels', label: 'Pixels / Duckets' },
    { value: 'diamonds', key: 'wiredfurni.params.reward.type.diamonds', label: 'Diamonds' },
    { value: 'points', key: 'wiredfurni.params.reward.type.points', label: 'Extra Currency' },
    { value: 'furni', key: 'wiredfurni.params.reward.type.furni', label: 'Furni' },
    { value: 'respect', key: 'wiredfurni.params.reward.type.respect', label: 'Respect' }
];

const SELECTABLE_REWARD_TYPES = REWARD_TYPES.filter((entry) => entry.value !== 'respect');

const createReward = (): RewardEntry => ({
    rewardType: 'furni',
    rewardValue: '',
    probability: DEFAULT_PROBABILITY,
    pointsType: DEFAULT_POINTS_TYPE
});

const getRewardValuePlaceholder = (rewardType: RewardType) => {
    switch (rewardType) {
        case 'badge':
            return 'Badge code';
        case 'credits':
            return 'Credits amount';
        case 'pixels':
            return 'Pixels amount';
        case 'diamonds':
            return 'Diamonds amount';
        case 'points':
            return 'Amount';
        case 'furni':
            return 'Furni base item id';
        case 'respect':
            return 'Respect amount';
    }
};

const getExtraFieldLabel = (rewardType: RewardType) => {
    switch (rewardType) {
        case 'points':
            return 'Currency Type';
        case 'badge':
            return 'Code';
        default:
            return 'Info';
    }
};

const getExtraFieldPlaceholder = (rewardType: RewardType) => {
    switch (rewardType) {
        case 'points':
            return 'Type id (e.g. 105)';
        case 'badge':
            return 'Badge';
        default:
            return '';
    }
};

const parseRewardEntry = (rawType: string, rawCode: string, rawProbability: string): RewardEntry => {
    const probability = Number(rawProbability);
    const parsedProbability = Number.isFinite(probability) ? probability : DEFAULT_PROBABILITY;

    if (rawType === '0') {
        return { rewardType: 'badge', rewardValue: rawCode, probability: parsedProbability, pointsType: DEFAULT_POINTS_TYPE };
    }

    const separatorIndex = rawCode.indexOf('#');

    if (separatorIndex === -1) {
        return { rewardType: 'furni', rewardValue: rawCode, probability: parsedProbability, pointsType: DEFAULT_POINTS_TYPE };
    }

    const rewardType = rawCode.slice(0, separatorIndex);
    const rewardValue = rawCode.slice(separatorIndex + 1);

    if (rewardType.startsWith('points')) {
        const pointsType = Number(rewardType.slice('points'.length));

        return {
            rewardType: 'points',
            rewardValue,
            probability: parsedProbability,
            pointsType: Number.isFinite(pointsType) ? pointsType : DEFAULT_POINTS_TYPE
        };
    }

    if (REWARD_TYPES.some((entry) => entry.value === rewardType)) {
        return { rewardType: rewardType as RewardType, rewardValue, probability: parsedProbability, pointsType: DEFAULT_POINTS_TYPE };
    }

    if (rewardType === 'cata') {
        return { rewardType: 'furni', rewardValue, probability: parsedProbability, pointsType: DEFAULT_POINTS_TYPE };
    }

    return { rewardType: 'furni', rewardValue: rawCode, probability: parsedProbability, pointsType: DEFAULT_POINTS_TYPE };
};

const encodeRewardCode = (reward: RewardEntry, rewardValue: string) => {
    if (reward.rewardType === 'badge') return rewardValue;
    if (reward.rewardType === 'points') return `points${Math.max(0, reward.pointsType)}#${rewardValue}`;

    return `${reward.rewardType}#${rewardValue}`;
};

/** The official GiveReward validation, on our typed rows: null when the rewards can be saved. */
export const validateRewards = (rewards: RewardEntry[], unique: boolean): string | null => {
    let sum = 0;

    for (const reward of rewards) {
        const rewardValue = reward.rewardValue.trim();

        if (!rewardValue) continue;

        if (rewardValue.includes(',')) return "Product/badge codes must not contain ',' characters.";
        if (rewardValue.includes(';')) return "Product/badge codes must not contain ';' characters.";
        if (encodeRewardCode(reward, rewardValue).length > MAX_CODE_LENGTH) return `Product/badge codes cannot contain more than ${MAX_CODE_LENGTH} characters.`;

        if (unique) continue;

        if (!Number.isFinite(reward.probability)) return 'Make sure are probabilities are numbers.';
        if (reward.probability < 1 || reward.probability > 100) return 'Make sure all probabilities are numbers between 1 and 100.';

        sum += Math.floor(reward.probability);
    }

    return sum > 100 ? `The sum of probabilities cannot exceed 100. You now have ${sum}.` : null;
};

export const WiredActionGiveRewardView: FC<{}> = (props) => {
    const [limitEnabled, setLimitEnabled] = useState(false);
    const [rewardTime, setRewardTime] = useState(1);
    const [uniqueRewards, setUniqueRewards] = useState(false);
    const [rewardsLimit, setRewardsLimit] = useState(1);
    const [limitationInterval, setLimitationInterval] = useState('1');
    const [error, setError] = useState<string | null>(null);
    const [rewards, setRewards] = useState<RewardEntry[]>([]);
    const { trigger = null, setIntParams = null, setStringParam = null, setSourceTypes = null } = useWired();
    const [userSource, setUserSource] = useState<number>(() => slotUserSource(trigger));

    const addReward = () => setRewards((rewards) => (rewards.length >= MAX_REWARDS ? rewards : [...rewards, createReward()]));
    const hasCustomCurrencyReward = rewards.some((reward) => reward.rewardType === 'points');

    const removeReward = (index: number) => {
        setRewards((prevValue) => {
            const newValues = Array.from(prevValue);

            newValues.splice(index, 1);

            return newValues;
        });
    };

    const updateReward = (index: number, updater: (reward: RewardEntry) => RewardEntry) => {
        setRewards((prevValue) => prevValue.map((reward, rewardIndex) => (rewardIndex === index ? updater(reward) : reward)));
    };

    const validate = () => {
        const nextError = validateRewards(rewards, uniqueRewards);

        setError(nextError);

        return !nextError;
    };

    const save = () => {
        const stringRewards: string[] = [];

        for (const reward of rewards) {
            const rewardValue = reward.rewardValue.trim();

            if (!rewardValue) continue;

            const probability = Number.isFinite(reward.probability) ? Math.floor(reward.probability) : 0;
            const rewardsString = [reward.rewardType === 'badge' ? '0' : '1', encodeRewardCode(reward, rewardValue), (uniqueRewards ? DEFAULT_PROBABILITY : probability).toString()];

            stringRewards.push(rewardsString.join(','));
        }

        const interval = parseInt(limitationInterval, 10);

        // Official: always saved, also without rewards. Layout [interval, unique, limit (0 when not
        // set), n (at least 1)]; the user source is the official source slot.
        setStringParam(stringRewards.join(';'));
        setIntParams([rewardTime, uniqueRewards ? 1 : 0, limitEnabled ? rewardsLimit : 0, interval >= 1 ? interval : 1]);
        setSourceTypes({ furni: [], users: [userSource] });
    };

    useEffect(() => {
        const readRewards: RewardEntry[] = [];

        if (trigger.stringData.length > 0) {
            const splittedRewards = trigger.stringData.split(';');

            for (const rawReward of splittedRewards) {
                const reward = rawReward.split(',');

                if (reward.length !== 3) continue;

                readRewards.push(parseRewardEntry(reward[0], reward[1], reward[2]));
            }
        }

        if (readRewards.length === 0) readRewards.push(createReward());

        const interval = trigger.intData.length > 0 ? trigger.intData[0] : 0;
        const limit = trigger.intData.length > 2 ? trigger.intData[2] : 0;

        setRewardTime(interval);
        setUniqueRewards(trigger.intData.length > 1 ? trigger.intData[1] === 1 : false);
        setRewardsLimit(limit > 0 ? clampPrizeLimit(limit) : MIN_PRIZE_LIMIT);
        setLimitationInterval(interval > 0 && trigger.intData.length > 3 ? String(Math.max(1, trigger.intData[3])) : '1');
        setLimitEnabled(limit > 0);
        setError(null);
        setUserSource(slotUserSource(trigger));
        setRewards(readRewards);
    }, [trigger]);

    return (
        <NativeTextTree>
            <WiredActionBaseView
                hasSpecialInput={true}
                requiresFurni={WiredFurniType.STUFF_SELECTION_OPTION_NONE}
                save={save}
                validate={validate}
                footer={<WiredSourcesSelector showUsers={true} userSource={userSource} onChangeUsers={setUserSource} />}
            >
                <div className="flex flex-col gap-1">
                    <Text bold>Reward limit</Text>
                    <div className="flex items-center gap-1">
                        <Checkbox checked={limitEnabled} id="limitEnabled" aria-label="Reward limit" onCheckedChange={(nextChecked) => setLimitEnabled(nextChecked)} />
                        <Text>{LocalizeText('wiredfurni.params.prizelimit', ['amount'], [''])}</Text>
                        <OctaneInput
                            aria-label="Prize limit"
                            className="w-[60px]"
                            max={MAX_PRIZE_LIMIT}
                            min={MIN_PRIZE_LIMIT}
                            type="number"
                            value={rewardsLimit}
                            onChange={(event) => setRewardsLimit(clampPrizeLimit(Number(event.target.value)))}
                        />
                    </div>
                    {!limitEnabled && (
                        <Text small className="text-[#cc0000]">
                            Reward limit not set. Make sure rewards are badges or non-tradeable items.
                        </Text>
                    )}
                </div>
                <div className="flex flex-col gap-1">
                    <Text bold>How often can a user be rewarded</Text>
                    <div className="grid grid-cols-2 gap-x-2 gap-y-1 w-fit">
                        {REWARD_INTERVALS.map((option) => (
                            <label key={option.value} className="flex items-center gap-1 cursor-pointer">
                                <Radio checked={rewardTime === option.value} name="rewardInterval" onCheckedChange={() => setRewardTime(option.value)} />
                                <Text>{option.label}</Text>
                            </label>
                        ))}
                    </div>
                    <div className="flex items-center gap-1">
                        <Text>n =</Text>
                        <OctaneInput
                            aria-label="n ="
                            className="w-[60px]"
                            disabled={rewardTime === 0}
                            maxLength={4}
                            value={limitationInterval}
                            onChange={(event) => setLimitationInterval(event.target.value.replace(/[^0-9]/g, '').slice(0, 4))}
                        />
                    </div>
                </div>
                <div className="flex flex-col gap-1">
                    <Text bold>Unique Rewards</Text>
                    <div className="flex items-center gap-1">
                        <Checkbox checked={uniqueRewards} id="uniqueRewards" aria-label="Unique Rewards?" onCheckedChange={(nextChecked) => setUniqueRewards(nextChecked)} />
                        <Text>Unique Rewards?</Text>
                    </div>
                    <Text small>If checked each reward will be given once to each user. Probabilities are not in use.</Text>
                </div>
                <div className="flex items-center justify-between">
                    <Text bold>Rewards</Text>
                    <Button disabled={rewards.length >= MAX_REWARDS} variant="secondary" onClick={addReward}>
                        Add reward
                    </Button>
                </div>
                <div className="flex flex-col gap-2">
                    {rewards &&
                        rewards.map((reward, index) => {
                            const rewardTypeOptions = reward.rewardType === 'respect' ? REWARD_TYPES : SELECTABLE_REWARD_TYPES;

                            return (
                                <div key={index} className="flex flex-col gap-1 p-1 rounded bg-muted">
                                    <div className="flex items-center gap-1">
                                        <Select
                                            aria-label="Reward type"
                                            className="min-w-0 grow form-select form-select-sm"
                                            value={reward.rewardType}
                                            onChange={(event) =>
                                                updateReward(index, (prevValue) => ({ ...prevValue, rewardType: event.target.value as RewardType, rewardValue: '' }))
                                            }
                                        >
                                            {rewardTypeOptions.map((entry) => (
                                                <option key={entry.value} value={entry.value}>
                                                    {localizeWithFallback(entry.key, entry.label)}
                                                </option>
                                            ))}
                                        </Select>
                                        {index > 0 && (
                                            <Button variant="danger" onClick={() => removeReward(index)}>
                                                <FaTrash className="fa-icon" />
                                            </Button>
                                        )}
                                    </div>
                                    <div className="flex flex-col gap-[2px]">
                                        <Text small bold>
                                            {getRewardValuePlaceholder(reward.rewardType)}
                                        </Text>
                                        <OctaneInput
                                            aria-label={getRewardValuePlaceholder(reward.rewardType)}
                                            placeholder={getRewardValuePlaceholder(reward.rewardType)}
                                            type={reward.rewardType === 'badge' ? 'text' : 'number'}
                                            value={reward.rewardValue}
                                            onChange={(event) => updateReward(index, (prevValue) => ({ ...prevValue, rewardValue: event.target.value }))}
                                        />
                                    </div>
                                    <div className="flex gap-1">
                                        <div className="flex flex-col gap-[2px] min-w-0 grow">
                                            <Text small bold>
                                                Chance %
                                            </Text>
                                            {uniqueRewards ? (
                                                <Text small className="px-1">
                                                    Unique
                                                </Text>
                                            ) : (
                                                <OctaneInput
                                                    aria-label="Chance %"
                                                    min={0}
                                                    max={100}
                                                    type="number"
                                                    value={reward.probability}
                                                    onChange={(event) => updateReward(index, (prevValue) => ({ ...prevValue, probability: Number(event.target.value) }))}
                                                />
                                            )}
                                        </div>
                                        {reward.rewardType === 'points' && (
                                            <div className="flex flex-col gap-[2px] min-w-0 grow">
                                                <Text small bold>
                                                    {getExtraFieldLabel(reward.rewardType)}
                                                </Text>
                                                <OctaneInput
                                                    aria-label={getExtraFieldLabel(reward.rewardType)}
                                                    min={0}
                                                    placeholder={getExtraFieldPlaceholder(reward.rewardType)}
                                                    type="number"
                                                    value={reward.pointsType}
                                                    onChange={(event) => updateReward(index, (prevValue) => ({ ...prevValue, pointsType: Number(event.target.value) }))}
                                                />
                                            </div>
                                        )}
                                    </div>
                                </div>
                            );
                        })}
                </div>
                {error && (
                    <Text small className="text-red-500" data-testid="give-reward-error">
                        {error}
                    </Text>
                )}
                {hasCustomCurrencyReward && (
                    <Text center small className="p-1 rounded bg-muted">
                        Extra Currency uses Amount as the quantity and Currency Type as the purse type id. Example: amount 200 + type 105.
                    </Text>
                )}
            </WiredActionBaseView>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
3En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser