Skills based system

Both of you have incredible ideas tbh. Brian’s idea of throwing a boot into the mix because you’re not a good fisher is one fun way to bug my players (and a good way to get a good laugh, xD), but it also shows evolution… I like it tbh, but I like the idea of getting more logs better on the long run. Why? Because it means I can make the progress slightly slower, so you guys are on, quite literally, a 50-50 split. For trees, we can also drop random leaves through the drop library for instance, or just throw out random stuff…

Anyway, this function goes into ‘ResourceGatherer’ and is called for a variable known as ‘AmountToGive()’, right? Here’s what I tried doing, but it gives out a ton of logs at the start, and then gives you one log per coroutine… (Did I do something wrong in the code?):

In ‘ResourceGatherer.Gather()’, I implemented this to use that function:

// Some code here...

int amountToGive = DetermineAmountToGive(playerSkillLevel, target.GetMinSkillRequired(), target.GetMaxSkillRequired(), target.GetQuantityLeft());

            // if (UnityEngine.Random.value <= playerDropChance) {
            playerInventory.AddToFirstEmptySlot(target.ResourceToAccumulate(), amountToGive);
                target.quantityLeft -= amountToGive;
                AccumulateXP();
            // }

// Some other code here...

So the way it works is as follows (I don’t know how you analyze this stuff in your head, but good job btw):

  1. If you’re level 1, you get 3 logs on the first shot, and then 1 log each until the tree dies…
  2. If you’re a higher level, you get a max of 5 per shot, and then 1 log each until the tree dies…

etc etc

So in a nutshell, you get ‘x’ number of logs at the start, and then 1 log until the end… I was hoping for chances of multiple logs per hit (so you get ‘x’ logs per hit, and keep the limit under the number of resources my tree has left before it dies, and increase the limit of that number based on the level of the player… I think we did this in your current ‘DetermineAmountToGive()’ function), but it’s all good for now… (even if this fails, my system is more than enough to keep me happy for now xD, thanks to you and Brian)

I’ll go buy animations for now and try implement them into my game

Actually, no. It’s supposed to be;

  1. Do you get any resource? (This is the original bit that was there with RandomValue <= playerDropChance)
  2. If you do, how many do you get? (This is where the new function comes in)

This is determined for every hit

The maxResourcesPerHit is not how many resources are left, it’s how many are configured to drop (you had this originally as quantityPerHit iirc). You are not supposed to pass target.GetQuantityLeft(), it should be something like target.GetMaxResourcesPerHit(). It’s true that if the tree only has 2 left to give, it may still give 4. You could clamp the amountToGive once it has been calculated

int amountToGive = DetermineAmountToGive(playerSkillLevel, target.GetMinSkillRequired(), target.GetMaxSkillRequired(), target.GetMaxResourcesPerHit());
amountToGive = Mathf.Min(amountToGive, target.GetQuantityLeft());
// TODO: add to inventory and remove from resource

This will then only give 2 if it calculated 4 but only have 2 left, but 4 if it calculated 4 and have 10 left.

Ahh I tried manipulating the formula multiple times and tuning it to get it to give me anything but 1, but that failed… Checked the hierarchy, did some tuning there as well, but to no avail… This is the troubling part, even after I fixed my code and renamed my ‘quantityPerHit’ to ‘maxResourcesPerHit’

int amountToGive = DetermineAmountToGive(playerSkillLevel, target.GetMinSkillRequired(), target.GetMaxSkillRequired(), target.GetMaxResourcesPerHit());
            amountToGive = Mathf.Min(amountToGive, target.GetQuantityLeft());

            if (UnityEngine.Random.value <= playerDropChance) {
            
            // playerInventory.AddToFirstEmptySlot(target.ResourceToAccumulate(), 1);
            playerInventory.AddToFirstEmptySlot(target.ResourceToAccumulate(), amountToGive);
            // target.quantityLeft -= 1;
            target.quantityLeft -= amountToGive;
            AccumulateXP();
            

Something on the side though… I’m trying to copy the fighter components’ solution to animating the fights to my resource gathering system, so I can animate him cutting down a tree, mining a rock, fishing for fish (now that I think of it though, the animations for fishing will be a bit complex, because I’ll have an animation for starting to fish, one for the fishing itself, and then one for when you get a fish, and then you either walk away, or go back to fishing), etc…

My question here is, how do we re-program the trigger system to count for multiple animation types? So for example if I have a mining animation, I want to set the trigger for that. If I have a woodcutting animation, I want to set the trigger for that, etc… Are ‘if’ statements going to be enough for this case?

Here is what I coded so far (in ‘ResourceGatherer.cs’, for my animations):

    private void TriggerGather() {

        playerInventory.GetComponent<Animator>().ResetTrigger("stopGather");
        playerInventory.GetComponent<Animator>().SetTrigger("gather");

    }

private void StopGather() {

        GetComponent<Animator>().ResetTrigger("gather");
        GetComponent<Animator>().SetTrigger("stopGather");

    }

Edit: yes it was enough, so I solved this… my problem with my animations now is that they still have to play once more (for some reason) after the resource item is destroyed. Any suggested ideas on how to make this go away? When the resource item is destroyed, I just want him to finish off his current animation, and then return to locomotion. I set the animator up in a similar way to the ‘Attack’ state we had in the animator, but something is still missing…

What are your values in the resource? The minSillRequired, maxSkillRequired, maxResourcesPerHit and total quantity?

Also, do the playerDropChance check first. It’s less processing and there’s no reason to determine how many resources you will give if you’re not going to give any. You’re doing all the math and the loop for no reason.

I’ll look at the rest of the post afterwards

‘minSkillRequired’ = 0
‘maxSkillRequired’ = 30
‘maxResourcesPerHit’ = 5
‘totalQuantity’ = 10

and my ‘playerDropChance’ is done prior to gathering the stuff, as you can see here:

public void Gather() {

        if (target.quantityLeft > 0) {

            var playerSkillLevel = skillStore.GetSkillLevel(target.GetAssociatedSkill());
            var playerDropChance = Mathf.Clamp01((playerSkillLevel - target.minSkillRequired)/(float)(target.maxSkillRequired - target.minSkillRequired));

            int amountToGive = DetermineAmountToGive(playerSkillLevel, target.GetMinSkillRequired(), target.GetMaxSkillRequired(), target.GetMaxResourcesPerHit());
            amountToGive = Mathf.Min(amountToGive, target.GetQuantityLeft());

            if (UnityEngine.Random.value <= playerDropChance) {
            
            // playerInventory.AddToFirstEmptySlot(target.ResourceToAccumulate(), 1);
            playerInventory.AddToFirstEmptySlot(target.ResourceToAccumulate(), amountToGive);
            // target.quantityLeft -= 1;
            target.quantityLeft -= amountToGive;
            AccumulateXP();
            
            }

        }

        if (target.quantityLeft <= 0) {
            target.DestroyResource();
        }

    }

P.S: ‘maxResourcesPerHit()’ function refers to what I once had known as ‘quantityPerHit’ in ‘ResourceGathering.cs’

I saw what you did. You calculate the amount first. This is what I said: Don’t do that. You don’t need to calculate how many resources will be dropped if you are not going to drop any. It’s wasted processing and time.

Hmmmm, let me analyse it and see what’s up. Like I said, I was spitballing and my math isn’t great. It should work, it may just be extremely frugal

I tried shifting the ‘amountToGive’ to the ‘if’ statement to counter for what you said… A little better I suppose (not 100% sure, but on average at higher levels now I get 2 logs/cut)

ANYWAY… The math takes a while, depending on the level, but eventually it works well. The probabilistic idea was a success, thanks @bixarrio :slight_smile:

I’ll take a short break and start a short question regarding a few bugs I found, then I will continue this question (I’ll shift it to ‘Talk’ so it doesn’t disappear), if I still have any questions regarding the Skills-based system

So I analysed this a bit and found this;

What we want

Without the randomness, the number of resources should increase by 1 every 6 levels ((max - min) / resourcesPerHit or (30 - 0) / 5 = 6). Levels 0 to 5 will then get 0 extra (we round down), 6 - 12 will get 1 extra, 13 - 17 will get 2 extra, 18 - 23 will get 3 extra and 24 - 30 will get 4 extra. We guarantee 1 if the playerDropChance check is a success, and then add between 0 and 4 more.

The randomness makes this so there’s only a chance of the above, and level 6 will have less of a chance to get the extra 1 than level 12, level 13 will have less chance to get the extra 2 than level 17, etc.

Except it doesn’t.

We subtract the i from the random number which, I believe, is somewhat taking the previous levels into account but we’ve already handled the previous levels. We should only subtract 1 level at a time.

If you change this line in the for loop:

randomValue -= i; // 'use' random value

to

randomValue -= 1; // 'use' random value

we get what I said above.

Note that there is still a chance a player with a skill level of 30 may still only get 1 resource. It’s a much smaller chance, but it is still there. If Random.value returns a very low number, say 0.1f we will have a final random value of 0.1f * 30 = 3 and the loop will only execute 3 times, giving us 0.167 * 3 = 0.5f which rounds down to 0.

Edit
Technically, with the change to subtract 1 from the random value, we now no longer need the loop. We can calculate the resources to give without the loop because it will only loop while randomValue is greater than 0 and there’s a linear decrease in the random value. It will probably be something like
amountToGive = ceil(randomValue) * fractionPerLevel;
The function will then change to

private int DetermineAmountToGive(int playerSkillLevel, int minLevel, int maxLevel, int maxResourcesPerHit)
{
    var randomValue = Mathf.CeilToInt(Random.value * (playerSkillLevel - minLevel));
    var fractionPerLevel = maxResourcesPerHit / (maxLevel - minLevel);
    var amountToGive = 1f; // a float because we use fractions
    amountToGive += randomValue * fractionPerLevel;
    return Mathf.FloorToInt(amountToGive);
}

We use the Ceiling of the random value because the loop would’ve still executed once for a value above 0 but below 1. We then just use this to multiply the fraction - which is what the loop would’ve been doing by looping ⌈randomValue⌉ times and adding the fraction.

Another Edit
This is still a little broken. A player at level 60 will get more than 5 resources. We’d probably want to limit the player level when determining the random value
var randomValue = Mathf.CeilToInt(Random.value * (Mathf.Min(playerSkillLevel, maxLevel) - minLevel));
The new function will then change to

private int DetermineAmountToGive(int playerSkillLevel, int minLevel, int maxLevel, int maxResourcesPerHit)
{
    var randomValue = Mathf.CeilToInt(Random.value * (Mathf.Min(playerSkillLevel, maxLevel) - minLevel));
    var fractionPerLevel = maxResourcesPerHit / (maxLevel - minLevel);
    var amountToGive = 1f; // a float because we use fractions
    amountToGive += randomValue * fractionPerLevel;
    return Mathf.FloorToInt(amountToGive);
}

It just lets a player at level 60 have the same result as a player at level 30. If you want the player at level 60 to have a better chance, you’d clamp the result instead
The function will then change to

private int DetermineAmountToGive(int playerSkillLevel, int minLevel, int maxLevel, int maxResourcesPerHit)
{
    var randomValue = Mathf.CeilToInt(Random.value * (playerSkillLevel - minLevel));
    var fractionPerLevel = maxResourcesPerHit / (maxLevel - minLevel);
    var amountToGive = 1f; // a float because we use fractions
    amountToGive += randomValue * fractionPerLevel;
    return Mathf.FloorToInt(Mathf.Min(amountToGive, maxResourcesPerHit));
}

This will then prevent the player from getting more resources than defined, but a player at level 60 will have a far greater chance of getting the max than a player at level 30

Well that actually did work, thanks @bixarrio - however, I have a small issue with my animations. Here’s what’s wrong:

My animations were set up similar to what I did for my ‘fighter.cs’, with the same transitions and all of that… Part of my issue is that if I kill my tree, my player will play the animation once more before stopping the animation for some reason. I tried modifying my code, since my transitions are exactly like my ‘Fighter.cs’, but that failed too (I learned the hard way not to delete ‘target = null’)… Any suggestions on how we can fix this?

And I also updated my ‘Cancel()’ function as follows:

public void Cancel()
    {
        StopGatheringAnimation(); // stops the player from playing the woodcutting animation if we walk away
        target = null;
        GetComponent<Mover>().Cancel();
    }

I don’t know what your animation code looks like and where it executes. Setting target = null would stop the gather, and if you trigger the animation in the gather behaviour it should not execute again.

The Cancel() looks good

Well, it was a ‘slightly’ tricky problem for me, but I solved it. The solution was that if I had any resources, as in ‘target.quantityLeft > 0’, then AND ONLY THEN do you start the animation, in ‘Gather()’, before you start the maths that determines how many logs you get, and your chances of getting them, and if ‘target.quantityLeft <= 0’, then you call ‘StopGatheringAnimation’ (so that the animation stops playing when the tree is destroyed), and my GatheringAnimation set of code (the ‘StartGatheringAnimation()’ and ‘StopGatheringAnimation()’) was copy-pasted from ‘Fighter.cs’, but also I tuned the trigger names

So for now I’ll call this topic a day and come back tomorrow with a new attempt for my next set of skills (I’ll try programming it alone first before I ask any questions) :slight_smile:

I look at your Gather() method and I don’t see where you are triggering the animation. It should - by now - look something like this

public void Gather()
{
    if (target.quantityLeft > 0)
    {
        // TRIGGER THE ANIMATION HERE
        
        var playerSkillLevel = skillStore.GetSkillLevel(target.GetAssociatedSkill());
        var playerDropChance = Mathf.Clamp01((playerSkillLevel - target.minSkillRequired)/(float)(target.maxSkillRequired - target.minSkillRequired));

        if (UnityEngine.Random.value <= playerDropChance)
        {
            int amountToGive = DetermineAmountToGive(playerSkillLevel, target.GetMinSkillRequired(), target.GetMaxSkillRequired(), target.GetMaxResourcesPerHit());
            amountToGive = Mathf.Min(amountToGive, target.GetQuantityLeft());

            playerInventory.AddToFirstEmptySlot(target.ResourceToAccumulate(), amountToGive);
            target.quantityLeft -= amountToGive;
            AccumulateXP();
        }
    }

    if (target.quantityLeft <= 0)
    {
        target.DestroyResource();
    }
}

You should probably also set target = null when the resources are done so the behaviour stops

It does more like this (my problem is solved in the comment in ‘target.quantityLeft <= 0’):

public void Gather()
{
    if (target.quantityLeft > 0)
    {
        // TRIGGER THE ANIMATION HERE
        
        var playerSkillLevel = skillStore.GetSkillLevel(target.GetAssociatedSkill());
        var playerDropChance = Mathf.Clamp01((playerSkillLevel - target.minSkillRequired)/(float)(target.maxSkillRequired - target.minSkillRequired));

        if (UnityEngine.Random.value <= playerDropChance)
        {
            int amountToGive = DetermineAmountToGive(playerSkillLevel, target.GetMinSkillRequired(), target.GetMaxSkillRequired(), target.GetMaxResourcesPerHit());
            amountToGive = Mathf.Min(amountToGive, target.GetQuantityLeft());

            playerInventory.AddToFirstEmptySlot(target.ResourceToAccumulate(), amountToGive);
            target.quantityLeft -= amountToGive;
            AccumulateXP();
        }
    }

    if (target.quantityLeft <= 0)
    {
        target.DestroyResource();
// Stop the Resource Gathering Animation here
    }
}

I guess it depends on your animation. You shouldn’t need to stop it. It should only play once. When there’s resources to gather, you trigger the animation. When the animation is done, it transitions out and don’t execute again. The next time the gather behaviour runs, it sees more resources and triggers the animation again. The stop is there so the animation will cancel before it is completed and we decide to walk away.

If you have an animation event in the animation that triggers the Gather it would be a little different. The animation is triggered in GatherBehaviour() if the timeSinceLastGather is greater than the timeBetweenGathers. It will then run the animation, the animation will trigger Gather(), Gather() will give resources, and if there are none left, set target = null which means the GatherBehaviour won’t run again and the animation won’t trigger again

Well… And it also cuts the animation out when the player finished resource gathering, but when it’s placed in the right spot xD

Respectfully… NOPE, JUST NOPE. DO NOT SET THE TARGET TO NULL WHEN THE RESOURCE DIES. ONLY DO SO IN ‘IAction.Cancel()’. Doing so when the resource dies will only tell the player not to target anything else the mouse points on, making him unmovable (P.S: I tried it, it did exactly what I described)

[NEW TOPIC, but relevant to skills, hence why it’s here]

hi again @bixarrio and @Brian_Trotter. Something I noticed during combat, considering that we shifted the whole game to a ‘Skill-based system’, is that no matter what you do so far, your final XP runs to whatever XP is relevant to which weapon you used to cast the final blow. In other words, you can have a ranged fight that is 99% done in ranged, but the last 1% is done in attack, then attack gets all the experience, and ranged gets nothing… kind of like the kid that gets all the credit in high school, but originally he copied the smart guy’s homework, and the smart guy unfortunately got no credit for his work

To counter this, I want to develop an algorithm that keeps track of what weapon did what damage, and average the damage out in the end, for each combat skill (the skills will be attack, ranged and magic). The Skill that dealt the most damage will get the XP, the rest gets nothing. Any suggestions on how can I start working on this? (I know this is not a topic that is as easy as it sounds)

Edit: OK so here’s what I tried doing, but this was a complete, utter failure:

  1. I created a new script, called ‘DamageComponent’, which starts with a private dictionary, which I attached to my player, and that script contains two functions.

The first is a dictionary, which I called ‘DealDamage()’, which adds damage dealt to a dictionary responsible for carrying the damage dealt by the player

The second function was known as ‘GetMostDamagingSkill()’, and that one accumulates the damage of the values in the dictionary, adds them up, and returns the mostDamagingSkill, and both functions are shown below:

using System.Collections.Generic;
using RPG.Skills;
using UnityEngine;

namespace RPG.Combat {

public class DamageComponent : MonoBehaviour {

    private Dictionary<Skill, float> damagePerSkill = new Dictionary<Skill, float>();

    public void DealDamage(Skill skill, float damage) {

        if (damagePerSkill.ContainsKey(skill)) {
            damagePerSkill[skill] += damage;
        }
        
        else damagePerSkill.Add(skill, damage);

    }

    public Skill GetMostDamagingSkill() {

        Skill mostDamagingSkill = Skill.None;    // I know this is supposed to be null, I'm just not sure how to create a null Skill type... the compiler returns errors
        float maxDamage = 0;

        foreach (var keyValuePair in damagePerSkill) {

            if (keyValuePair.Value > maxDamage) {
                maxDamage = keyValuePair.Value;
                mostDamagingSkill = keyValuePair.Key;
                }

            }

        return mostDamagingSkill;

        }

    }

}
  1. Next up, I introduced a variable called ‘playerDamageComponent’, which I initialized in ‘Awake()’, and the function known as ‘GetDamage()’ in ‘Fighter.cs’ about a week ago was tuned, and this function was responsible for splitting up the XP to reward whoever had the final hit the experience needed, and it’s eventually called in ‘Fighter.Hit()’. I tuned this function a little bit, to compensate for the new function, as shown below:
public DamageComponent playerDamageComponent;

private void Awake() {
// some code here...
playerDamageComponent = GetComponent<DamageComponent>();
// some other code...
}

public int GetDamage() {
        if (currentWeaponConfig.GetSkill() == Skill.Attack) {
            // return (int)(GetComponent<BaseStats>().GetStat(Stat.Attack) + (currentWeaponConfig.GetDamage() * (1 + currentWeaponConfig.GetPercentageBonus() / 100)));
            int attackDamage = (int) (GetComponent<BaseStats>().GetStat(Stat.Attack) + (currentWeaponConfig.GetDamage() * (1 + currentWeaponConfig.GetPercentageBonus()/100)));
            playerDamageComponent.DealDamage(Skill.Attack, attackDamage);
            return attackDamage;
        }
        else if (currentWeaponConfig.GetSkill() == Skill.Ranged) {
            // return (int) (GetComponent<BaseStats>().GetStat(Stat.Ranged) + (currentWeaponConfig.GetDamage() * (1 + currentWeaponConfig.GetPercentageBonus()/100)));
            int rangedDamage = (int)(GetComponent<BaseStats>().GetStat(Stat.Ranged) + (currentWeaponConfig.GetDamage() * (1 + currentWeaponConfig.GetPercentageBonus() / 100)));
            playerDamageComponent.DealDamage(Skill.Ranged, rangedDamage);
            return rangedDamage;
            }
        else {
            // return (int) (GetComponent<BaseStats>().GetStat(Stat.Magic) + (currentWeaponConfig.GetDamage() * (1 + currentWeaponConfig.GetPercentageBonus()/100)));
            int magicDamage = (int)(GetComponent<BaseStats>().GetStat(Stat.Magic) + (currentWeaponConfig.GetDamage() * (1 + currentWeaponConfig.GetPercentageBonus() / 100)));
            playerDamageComponent.DealDamage(Skill.Magic, magicDamage);
            return magicDamage;
            }
        }
  1. Finally, in ‘Health.AwardExperience()’, I tried to award the experience to whoever did the most damage, as shown below:
        private void AwardExperience(GameObject instigator, Skill skill, Skill otherSkill = Skill.Defence) {

            if (instigator.TryGetComponent(out SkillExperience skillExperience)) {

                if (instigator.TryGetComponent(out Health otherHealth) && !otherHealth.IsDead()) {

                    // This line sends 2/3rd of the XP Reward to whichever skill is associated to the Players' weapon:
                    // skillExperience.GainExperience(skill, 2*Mathf.RoundToInt(GetComponent<AIController>().GetXPReward()/3));
                    skillExperience.GainExperience(GetComponent<DamageComponent>().GetMostDamagingSkill(), 2*Mathf.RoundToInt(GetComponent<AIController>().GetXPReward()/3));
                    // This line sends 1/3rd of the XP Reward to the defence XP, which is always 1/3rd of whatever the AI Reward XP is assigned to:
                    skillExperience.GainExperience(otherSkill, Mathf.RoundToInt(GetComponent<AIController>().GetXPReward()/3));

                }

            }

        }

These are all the changes I tried doing, but the system somehow still failed. Where did I go wrong…?

Try using actionScheduler.CancelCurrentAction() (though setting the target to null should do the trick, since the Update() loop should be starting with if(target==null) return;

It might make more sense to have this component on each enemy. The enemy is the one taking the damage, and if you do some damage to enemy A, and then for tactical reasons switch to enemy B, you don’t want to comingle their hit stats…

I’m not entirely sure we really need a new class for this purpose… this may be something we can manage right in Health itself…

        private Dictionary<Skill, float> damagePerSkill = new Dictionary<Skill, float>();

        private void AddDamage(Skill skill, float damage)
        {
            if (!damagePerSkill.ContainsKey(skill)) damagePerSkill[skill] = damage;
            else damagePerSkill[skill] += damage;
        }

        private Skill GetMostUsedSkill()
        {
            return damagePerSkill.OrderByDescending(s => s.Value).First().Key;
        }

Then TakeDamage() can handle dispensing the damage…
After making any damage adjustments (if any), add

AddDamage(skill, damage);

Finally, when awarding experience, pass in GetMostUsedSkill()

AwardExperience(instigator, GetMostUsedSkill());

Yup, that worked beautifully. Thank you again Brian :slight_smile: (I’m still struggling to understand dictionaries a bit, how to get and assign values context is something I had in my head for a moment… then it vanished. I’ll re-read this to properly get an idea)

This is a fairly standard pattern for adding to a counter in a Dictionary. The first line checks to see if the key is in use, and if it isn’t, it sets the value. if it is in use, then we add to it. I think you are familiar with that pattern, as I see you used it above.

This one is a little bit more complicated, I’ll confess. This takes advantage of the fact that Dictionaries implement an IEnumerable pattern that returns a KeyValuePair with the Key and Value types matching the Dictionary. So I applied a Linq expression to Dictionary. The tricky part of Linq expressions is that a good understanding of SQL is important to understand them.
This particular expression sorts the entries in DamagePerSkill by the value from Highest to Lowest. (OrderByDescending(s=>s.Value), Then the first value (which will be highest is returned (First()), and finally, the Key from the First is returned.

Frankly speaking, when I was trying this out on my own at first, the first thing I thought of was populating a dictionary for some reason, as it seemed like the easiest approach (keys that contain corresponding values, so…), so I went and tried copying some stuff from ‘SkillExperience.GainExperience()’

For this one, I took a wild guess that it utilized the fact that we have a dictionary that’s already filled by ‘AddDamage()’, as it was called AFTER ‘GetMostUsedSkill()’ in ‘Health.TakeDamage()’, and then when I saw how you went around it, I suddenly remembered what we did in the inventory… where we got the value of the items to know what stuff to keep on the players’ death. The only difference here is, we are getting the top of the list, as that’s what caused the most damage (but I didn’t know that we can access a dictionaries’ key through the keyword ‘Key’ in the end). Quite an interesting approach you got there

P.S: I never learned SQL, as I’m not a computer scientist :sweat_smile: - just trying to develop a passion project here, and gaining a lot of coding XP along the way

btw, whenever you have some time, please have a look at my minimap issue :slight_smile: (before we head back to the bank issue)

Privacy & Terms