CubeArtisan / cubeartisan

GNU Affero General Public License v3.0
6 stars 4 forks source link

Advanced Custom Format Specifications #40

Open ruler501 opened 3 years ago

ruler501 commented 3 years ago

What would you like to be able to do? Our current system for specifying custom packs is quite powerful, but has many limitations. Most notably you cannot have meaningful dependencies between slots(there is the dependencies created by the without replacement methodology, but those are almost impossible to utilize). There is also the issue of as we add more things you can specify on each pack the current ui is going to get cluttered and harder to use. Creating an advanced UI with the full capabilities could solve this. Personally I'd prefer some kind of DSL for specifying packs. It would allow you to specify all the packs in one file and enable attaching attributes to each pack separately. It would be created so that for each player the corresponding pack would have the same number of cards and the same attributes. For adding cards it would allow you to filter through the cube using our filter system to create lists that you could extract a random element from with or without replacement(in the case of without replacement this would propagate back to the cube through all intermediary lists, would have to decide if it should propagate to all other defined lists as well).

Describe how you would use the feature Here is a specification for packs I created in a DSL from an old project. We could make significant improvements on the syntax. Basically this specification adds rares, lands, and uncommons just by randomly selecting off filtering like we can currently do. Then for commons it ensures that 3 are one color, 3 are another color that is the other color's ally, one card that is both of those colors, a colorless card, and 2 cards without any further restrictions. In our current system it is impossible to specify the requirement that 3 cards are the same color which is chosen at random for each pack. A more in-depth explanation is that this specification creates 1, 15 card pack with no attributes. It has one card taken without replacement that is tagged as a Lands where there's one copy of each card, similarly one Rares with one copy. It specifies there are 2 copies of each of the cards marked Uncommons. It gets one card marked as Uncommon and Allies, it gets one card marked Uncommons and Mono, and one more card that just needs an Uncommons tag. For the commons it specifies 4 copies of each are available. Then it does does something that our current system can't do. It first creates a list of all Ally colored pairs, then it filters those to just the pairs where for both colors there are at least 3 cards tagged with Commons and that color. It chooses one of those pairs at random and adds 3 cards that are tagged Common and that specific color, it adds one card whose colors contains or equals the pair that has the Commons and Allies tag, it adds one card which has the Commons and Colorless tag, finally it adds two cards that just need to be tagged Commons.

Lands
    Any -> Card
    Add(Card)

Rares: 1
    Any -> Card
    Add(Card)

Uncommons: 2
    GetList('Allies') -> Card
    Add(Card)
    GetList('Mono') -> Card
    Add(Card)
    Any -> Card
    Add(Card)

Commons: 4
    Colors = ['White', 'Blue', 'Black', 'Red', 'Green']
    Allies = Zip(Colors, Rotate(Colors,1))
    [Allies as (X0, X1) where ContainsAtLeast(GetList(X0), 3) and ContainsAtLeast(GetList(X1), 3)] -> FinalColors
    FinalColors /> FColor, SColor
    FColorList = GetList(FColor)
    SColorList = GetList(SColor)
    Repeat 3 {
        FColorList -> Card
        Add(Card)
    }
    Repeat 3 {
        SColorList -> Card
        Add(Card)
    }
    Allies = [FColor, SColor]
    [GetList('Allies') as X where Subset(GetColors(X), Allies)] -> Card
    Add(Card)
    GetList('Colorless') -> Card
    Add(Card)
    Repeat 2 {
        Any -> Card
        Add(Card)
    }

Additional context Consideration needs to be given when creating the language to how difficult it will be to calculate asfans for the resultant packs.

I could also see this as a JSON object of some kind potentially, though I'm not entirely sure how things like variables carried between card definitions would work there.

I am leaning towards having no propagation of removal without replacement. It makes some things more verbose, but gives a lot of useful power and makes interactions much easier to understand.

As far as asfan goes any decently generic system will be extremely complicated to implement and likely very slow. I'd propose that we just do simulation instead. For the case of removal without replacement, if we generate 300,000 independent drafts then for any copy of a card that appears at least once every 660 player pools(cards 1 player opens for a draft) for an 8 player draft(170 for 2 player and 340 for 4 player) we can be about 99.5% sure that we are at most 5% off of the correct answer. If we want to do generation the same way we do now, that is assuming each players pool is independent(which is only roughly the case do to removal without replacement) we would need to generate 700,000 independent pools which is for all except the 2 player instance significantly less than generating complete drafts. For removal with replacement it is a little more complicated since you have to consider the standard deviation of the card's asfan. For the case of generating full drafts using 300,000 as above we'd have the same 99.5% surety of being within 5% of the correct asfan if the standard deviation of the asdraft(number of times it appears in any pool of 1 draft) is at most 70 times the asfan(for a pool) for 8 players(35 for 4 players, 18 for 2 players). Though it is important to note that since it is without replacement every pool should be independent. For just generating the pool using 700,000 samples as above you get that the stddev of the asfan(for a pool) must be at most 14 times the actual asfan of the pool for the desired accuracy. We'd have to run some performance tests to see how feasible hundreds of thousands of trials are, but it should provide very good results.

ruler501 commented 3 years ago

See dekkerglen/CubeCobra#1716 for more discussion.

Adding this as a comment so it can be addressed independently of the main issue. Here is roughly the description of the language I would propose. Dynamically typed so we don't have to implement a type checker, but variables still have types. 3 basic types which are String, Number, Card, and Array that holds a heterogeneous sequence of values. There are no references, everything is a separate value. There is one implicitly defined variable Cube which is an Array of Card objects containing the cards of the cube. A program would be a new line separated sequence of statements. There are 2 kinds of block statements that contain other statements, statements do not have to be in a block.

  1. A Setup Block is statically run once before any other code, it must be the first statement in the specification and there can be at most one, it is not required. All variables defined in this block as well as the Cube variable are shared between all players and updates made in the specification for one player will be carried over to the next player. They would be used to setup any variables that need to be shared between all players. An example would be adding extra copies of Commons so you only need 1 in your cube, but can generate as though there were 4. It would look like:
    Setup {
    ... 
    }
  2. A Repeat Block is just syntactic sugar for repeating a sequence of statements a fixed number of times. The parameter to the block must be an integer constant. It would look like:
    Repeat 4 {
    ... 
    }

    There are 5 other kinds of statements:

  3. Variable assignment <VariableName> = <ValueExpression> which works as you would expect
  4. Random Extraction(Random Sampling Without Replacement) <VariableName> -> <VariableName> where the first variable name must be a non-empty Array or an error stops execution. It pops a random element of the array and assigns it to the given variable name.
  5. Random Sampling(With Replacement) <ArrayValueExpression> +> <VariableName>. Basically the same as Extraction, but it doesn't change the array and so can also sample from temporary arrays.
  6. Add Card statement AddCard(<CardValueExpression>) adds the specified Card value to the current pack(see next statement). If the expression does not evaluate to a Card value execution halts with an error. This cannot appear in a Setup Block.
  7. Start Pack statement StartPack{property1 = <ConstantValue>, property2 = <ConstantValue>, ...}, or if no properties are specified StartPack{}. There would be a defined set of properties with specific types that would change how that pack is drafted. Examples would be whether it is sealed, how many cards to pick at a time, etc. This let's the generator know to start a new pack. There must be a Start Pack statement before any Add Card statements and Start Pack statements cannot appear in Setup blocks.

ValueExpressions are all pure(don't modify arguments or global state, and don't read global state other than looking up the values of specified variables) built as follows:

  1. Numeric Constants \d+(\.\d+)?, String Constants(double or single quoted using the same semantics as the filters), Array Constants [<ValueExpression>, ...].
  2. <VariableName> evaluates to the current value of the variable. Error if the variable is undefined.
  3. +, -, *, /, ^ as binary operators on numbers without any precedence. Errors when appropriate.
  4. <IntegerValueExpression> * <ArrayValueExpression> repeat the array the specified number of times.
  5. <ArrayValueExpression> + <ValueExpression> create a new array equal to the given array with the value added as the last element, specifically does not handle concatenating arrays. Equivalent to Concat(<ArrayValueExpression>, [<ValueExpression>]).
  6. <ArrayValueExpression> - <ValueExpression> create a new array equal to the given array with the first instance of the value removed. Error if the value is not in the array. Equivalent to RemoveValueAt(IndexOf(value, array), array).
  7. <ArrayValueExpression> @ <IntegerValueExpression> the value at the specified index of the array(1 indexed).
  8. Concat(<ArrayValueExpression>, <ArrayValueExpression>, ...) concatenate multiple arrays into one array value, type checking if needed. The arguments are alternatively allowed to be all either String values or Number values. In that case it converts them all to strings and concatenates the string.
  9. Range(<IntegerValueExpression>, <IntegerValueExpression>, <IntegerValueExpression>) can have 1-3 parameters. For 1 parameter it is the array [1, 2, ..., n], for 2 parameters it's [m, m + 1, ..., n], for 3 it is [m, m + o, m + 2*o, ..., n].
  10. Zip(<ArrayValueExpression>, <ArrayValueExpression>, ...) creates an array of arrays with the first dimension equal to the length of the shortest input array and inner dimension equal to the number of arrays provided. Each element at index i is the array made up of the elements at index i from each of the provided arrays.
  11. Rotate(<ArrayValueExpression>, <IntegerValueExpression>) does a circular right rotation of the array by the given number of positions.
  12. IndexOf(<ValueExpression>, <ArrayExpression>) the index of the array(1-indexed) where a value compares equal(deep equality) to the value. Error if no such element exists.
  13. GetPropertyOf(<CardValueExpression>, <StringValueExpression>) get the property of the card specified by the string. If the string does not specify a property error.
  14. LengthOf(<ArrayOrStringValueExpression>) the length of the array or string. If the value is neither error.
  15. UniqueElementsOf(<ArrayValue>) a new array where no two elements compare equal(deep equality) containing the maximal number of elements from the given array.
  16. RemoveValueAt(<IntegerValueExpression>, <ArrayValueExpression>) a new array equal to the given one with the value at the specified index(1 indexed) removed. If the index is not a valid index of the array error.
  17. <Expression> if <BooleanExpression> else <Expression> for conditional logic. Evaluates to the first expression if the boolean is true, or the second expression if it is false. Is not a block so you can't put statements in different branches leaving us with a single control flow path for statements.
  18. [<ValueExpression> for <VariableName> in <ArrayValueExpression> where <BooleanExpression>] which is where most computation will take place filters and transforms arrays. The expressions can access the current element of the given array using the given variable name. After the expression is done evaluating the given variable will have a value of the last value in the given array, or not be changed(including defined) if the given array is empty. You don't have to include <ValueExpression> for in which case it will be an identity transformation not modifying the values from the array when creating the new array. You similarly don't have to include where <BooleanExpression> in which case it won't filter out any elements of the array. So technically [<VariableName> in <ArrayValueExpression>] is valid and would just evaluate to the given array.

Finally there are boolean expressions which can only occur within comprehensions.

  1. <BooleanExpression> <and|or> <BooleanExpression> evaluates as expected.
  2. Not(<BooleanExpression>) evaluates to true iff its argument evaluates to false.
  3. <, <=, >=, > are binary operators on two strings in which case they behave lexicographically, or two numbers in which case they follow the standard ordering.
  4. <ValueExpression> == <ValueExpression> and <ValueExpression> != <ValueExpression> deep equality comparison between 2 values. Evaluates to false if they are different types.
  5. ElementOf(<ValueExpression>, <ArrayValueExpression>) evaluates to true iff there is an element of the array that compares equal(deep equality) to the value.
  6. Contains(<ArrayValueExpression>, <ArrayValueExpression>) evaluates to true iff every element of the second argument compares equal(deep equality) to different elements of the first argument(Multiset equality).
  7. Intersects(<ArrayValueExpression>, <ArrayValueExpression>) evaluates to LengthOf([X in A1 where ElementOf(X, A2)]) > 0.
  8. Subset(<ArrayValueExpression>, <ArrayValueExpression>) evaluates to LengthOf([X in A2 where ElementOf(X, A1)]) == LengthOf(A2)
  9. SetEquals(<ArrayValueExpression>, <ArrayValueExpression>) evaluates to Subset(A1, A2) and Subset(A2, A1)
  10. FilterMatches(<StringValueExpression>, <CardValueExpression>) evaluates to true iff the filter specified by the string is true for the card. Note that as of now the other boolean expression constructions are sufficient to do nearly everything the filters can do(exceptions for the mana cost being payable with queries) and more, but I think this would be appreciated to make it simpler to write and test.

I'll edit in examples of common requests later.

Dominaria without foil slot

Repeat 3 {
  StartPack{} 
  Repeat 10 {
    [card in Cube where FilterMatches("rarity:Common", card)] +> common
    AddCard(common)
  }
  Repeat 2 {
    [card in Cube where FilterMatches("rarity:Uncommon", card)] +> uncommon
    AddCard(uncommon)
  }
  [card in Cube where FilterMatches("type:Legendary type:Creature", card)] +> legend
  ([card in Cube where FilterMatches("rarity:Uncommon", card)] 
   if FilterMatches("-rarity:Uncommon", legend) else 
   [card in Cube where FilterMatches("rarity=Rare or rarity=Mythic", card)]) +> nonlegend
  AddCard(nonlegend)
  AddCard(legend)
}

Generic Set Cube

Setup {
  commons = 4 * [card in Cube where FilterMatches("rarity:Common", card)]
  uncommons = 2 * [card in Cube where FilterMatches("rarity:Uncommon", card)]
  rares = 1 * [card in Cube where FilterMatches("rarity=Rare or rarity=Mythic", card)]
}
Repeat 3 {
  StartPack{}
  Repeat 10 {
    commons -> common
    AddCard(common)
  }
  Repeat 3 {
    uncommons -> uncommon
    AddCard(uncommon)
  }
  rares -> rare
  AddCard(rare)
}

To convert a slot in a specification from our current UI. Star becomes an empty string filter. Remove the last line if done with replacement.

filters = [[card in Cube where FilterMatches(filter, card)] for filter in ["ci=2 -type:Land", "ci>=3", ""]]
[filter in filters where LengthOf(filter) > 0] +> cards
cards +> card
AddCard(card)
Cube = Cube - card