Repository: tableauio/tableau (go)

Issue:
union: support horizontal/vertical union list
### Predefined in proto
A `union` type should be predefined:

```protobuf
// Predefined union type.
message Target {
  option (tableau.union) = true;

  Type type = 9999 [(tableau.field) = { name: "Type" }];
  oneof value {
    option (tableau.oneof) = {
      field: "Field"
    };
    Pvp pvp = 1;      // Bound to enum value 1: TYPE_PVP.
    Pve pve = 2;      // Bound to enum value 2: TYPE_PVP.
    Story story = 3;  // Bound to enum value 3: TYPE_STORY.
    Skill skill = 4;  // Bound to enum value 4: TYPE_SKILL.
  }

  enum Type {
    TYPE_NIL = 0;
    TYPE_PVP = 1 [(tableau.evalue) = { name: "PVP" }];
    TYPE_PVE = 2 [(tableau.evalue) = { name: "PVE" }];
    TYPE_STORY = 3 [(tableau.evalue) = { name: "Story" }];
    TYPE_SKILL = 4 [(tableau.evalue) = { name: "Skill" }];
  }
  message Pvp {
    int32 type = 1;                          // scalar
    int64 damage = 2;                        // scalar
    repeated protoconf.FruitType types = 3;  // incell enum list
  }
  message Pve {
    Mission mission = 1;             // incell struct
    repeated int32 heros = 2;        // incell list
    map<int32, int64> dungeons = 3;  // incell map

    message Mission {
      int32 id = 1;
      uint32 level = 2;
      int64 damage = 3;
    }
  }
  message Story {
    protoconf.Item cost = 1;                     // incell predefined struct
    map<int32, protoconf.FruitType> fruits = 2;  // incell map with value as enum type
    map<int32, Flavor> flavors = 3;              // incell map with key as enum type
    message Flavor {
      protoconf.FruitFlavor key = 1 [(tableau.field) = { name: "Key" }];
      int32 value = 2 [(tableau.field) = { name: "Value" }];
    }
  }
  message Skill {
    int32 id = 1;      // scalar
    int64 damage = 2;  // scalar
    // no field tag 3
  }
}
```
## Horizontal union list
| ID |Target1Type | Target1Field1 | Target1Field2 | Target1Field3 |Target2Type | Target2Field1 | Target2Field2 | Target2Field3 |
|--|--|--|--|--|--|--|--|--|
| map<int32, Task> |  [.Target]enum<.Target.Type> | union| union | union |enum<.Target.Type> | union| union | union |
| ID | Target1's type | Target1's value field1 | Target1's value field2 | Target1's value field3 |Target2's type | Target2's value field1 | Target2's value field2 | Target2's value field3 |
| 1 | PVP | 1 | 10 | 100 | PVE | 1,100,999| 1,2,3 | 1:10,2:20,3:30 |
| 2 | PVE | 1,100,999| 1,2,3 | 1:10,2:20,3:30 |PVP | 1 | 10 | 100 |

## TODO
- [ ] #129

Pull request description:
table(union): support horizontal/vertical union list
- [x] close #49 

Patch:
diff --git a/internal/confgen/parser.go b/internal/confgen/parser.go
index decda62..3f7b6f5 100644
--- a/internal/confgen/parser.go
+++ b/internal/confgen/parser.go
@@ -904,6 +904,28 @@ func (sp *sheetParser) parseListField(field *Field, msg protoreflect.Message, rc
 				if !keyedListItemExisted {
 					list.Append(listItemValue)
 				}
+			} else if xproto.IsUnionField(field.fd) {
+				elemPresent := false
+				newListValue := list.NewElement()
+				colName := prefix + field.opts.Name
+				if field.opts.Span == tableaupb.Span_SPAN_INNER_CELL {
+					// incell union
+					cell, err := rc.Cell(colName, sp.IsFieldOptional(field))
+					if err != nil {
+						return false, xerrors.WithMessageKV(err, rc.CellDebugKV(colName)...)
+					}
+					if elemPresent, err = sp.parseIncellUnion(newListValue, cell.Data, field.opts.GetProp().GetForm()); err != nil {
+						return false, xerrors.WithMessageKV(err, rc.CellDebugKV(colName)...)
+					}
+				} else {
+					elemPresent, err = sp.parseUnionMessage(newListValue.Message(), field, rc, colName)
+					if err != nil {
+						return false, xerrors.WithMessageKV(err, rc.CellDebugKV(colName)...)
+					}
+				}
+				if elemPresent {
+					list.Append(newListValue)
+				}
 			} else {
 				elemPresent := false
 				newListValue := list.NewElement()
@@ -974,6 +996,12 @@ func (sp *sheetParser) parseListField(field *Field, msg protoreflect.Message, rc
 							return false, xerrors.WithMessageKV(err, rc.CellDebugKV(colName)...)
 						}
 					}
+				} else if xproto.IsUnionField(field.fd) {
+					// horizontal union list
+					elemPresent, err = sp.parseUnionMessage(newListValue.Message(), field, rc, colName)
+					if err != nil {
+						return false, xerrors.WithMessageKV(err, rc.CellDebugKV(colName)...)
+					}
 				} else {
 					// horizontal struct list
 					subMsgName := string(field.fd.Message().FullName())
@@ -1248,74 +1276,17 @@ func (sp *sheetParser) parseUnionField(field *Field, msg protoreflect.Message, r
 		return present, nil
 	}
 
-	unionDesc := xproto.ExtractUnionDescriptor(field.fd.Message())
-	if unionDesc == nil {
-		return false, xerrors.Errorf("illegal definition of union: %s", field.fd.Message().FullName())
-	}
-
-	// parse union type
-	typeColName := prefix + field.opts.Name + unionDesc.TypeName()
-	cell, err := rc.Cell(typeColName, sp.IsFieldOptional(field))
-	if err != nil {
-		return false, xerrors.WithMessageKV(err, rc.CellDebugKV(typeColName)...)
-	}
-
-	typeVal, present, err := sp.parseFieldValue(unionDesc.Type, cell.Data, nil)
+	present, err = sp.parseUnionMessage(structValue.Message(), field, rc, prefix+field.opts.Name)
 	if err != nil {
-		return false, xerrors.WithMessageKV(err, rc.CellDebugKV(typeColName)...)
-	}
-	structValue.Message().Set(unionDesc.Type, typeVal)
-
-	// parse value
-	fieldNumber := int32(typeVal.Enum())
-	if fieldNumber == 0 {
-		// default enum value 0, no need to parse.
-		return false, nil
-	}
-	valueFD := unionDesc.GetValueByNumber(fieldNumber)
-	if valueFD == nil {
-		typeValue := unionDesc.Type.Enum().Values().ByNumber(protoreflect.EnumNumber(fieldNumber)).Name()
-		return false, xerrors.WithMessageKV(xerrors.E2010(typeValue, fieldNumber), rc.CellDebugKV(typeColName)...)
-	}
-	fieldValue := structValue.Message().NewField(valueFD)
-	if valueFD.Kind() == protoreflect.MessageKind {
-		// MUST be message type.
-		md := valueFD.Message()
-		msg := fieldValue.Message()
-		for i := 0; i < md.Fields().Len(); i++ {
-			fd := md.Fields().Get(i)
-			colName := prefix + field.opts.Name + unionDesc.ValueFieldName() + strconv.Itoa(int(fd.Number()))
-			err := func() error {
-				subField := parseFieldDescriptor(fd, sp.opts.Sep, sp.opts.Subsep)
-				defer subField.release()
-				// incell scalar
-				cell, err := rc.Cell(colName, sp.IsFieldOptional(subField))
-				if err != nil {
-					return xerrors.WithMessageKV(err, rc.CellDebugKV(colName)...)
-				}
-				err = sp.parseUnionValueField(subField, msg, cell.Data)
-				if err != nil {
-					return xerrors.WithMessageKV(err, rc.CellDebugKV(colName)...)
-				}
-				return nil
-			}()
-			if err != nil {
-				return false, err
-			}
-		}
-	} else {
-		// scalar: not supported yet.
-		return false, xerrors.Errorf("union value (oneof) as scalar type not supported: %s", valueFD.FullName())
+		return false, xerrors.WithMessageKV(err, rc.CellDebugKV(prefix+field.opts.Name)...)
 	}
-	structValue.Message().Set(valueFD, fieldValue)
-
 	if present {
 		msg.Set(field.fd, structValue)
 	}
 	return present, nil
 }
 
-func (sp *sheetParser) parseUnionValueField(field *Field, msg protoreflect.Message, cellData string) error {
+func (sp *sheetParser) parseUnionMessageField(field *Field, msg protoreflect.Message, cellData string) error {
 	if field.fd.IsMap() {
 		// incell map
 		value := msg.NewField(field.fd)
@@ -1391,6 +1362,71 @@ func (sp *sheetParser) parseIncellUnion(structValue protoreflect.Value, cellData
 	}
 }
 
+func (sp *sheetParser) parseUnionMessage(msg protoreflect.Message, field *Field, rc *book.RowCells, prefix string) (present bool, err error) {
+	unionDesc := xproto.ExtractUnionDescriptor(field.fd.Message())
+	if unionDesc == nil {
+		return false, xerrors.Errorf("illegal definition of union: %s", field.fd.Message().FullName())
+	}
+
+	// parse union type
+	typeColName := prefix + unionDesc.TypeName()
+	cell, err := rc.Cell(typeColName, sp.IsFieldOptional(field))
+	if err != nil {
+		return false, xerrors.WithMessageKV(err, rc.CellDebugKV(typeColName)...)
+	}
+
+	var typeVal protoreflect.Value
+	typeVal, present, err = sp.parseFieldValue(unionDesc.Type, cell.Data, nil)
+	if err != nil {
+		return false, xerrors.WithMessageKV(err, rc.CellDebugKV(typeColName)...)
+	}
+	msg.Set(unionDesc.Type, typeVal)
+
+	// parse value
+	fieldNumber := int32(typeVal.Enum())
+	if fieldNumber == 0 {
+		// default enum value 0, no need to parse.
+		return false, nil
+	}
+	valueFD := unionDesc.GetValueByNumber(fieldNumber)
+	if valueFD == nil {
+		typeValue := unionDesc.Type.Enum().Values().ByNumber(protoreflect.EnumNumber(fieldNumber)).Name()
+		return false, xerrors.WithMessageKV(xerrors.E2010(typeValue, fieldNumber), rc.CellDebugKV(prefix)...)
+	}
+	fieldValue := msg.NewField(valueFD)
+	if valueFD.Kind() == protoreflect.MessageKind {
+		// MUST be message type.
+		md := valueFD.Message()
+		msg := fieldValue.Message()
+		for i := 0; i < md.Fields().Len(); i++ {
+			fd := md.Fields().Get(i)
+			valColName := prefix + unionDesc.ValueFieldName() + strconv.Itoa(int(fd.Number()))
+			err := func() error {
+				subField := parseFieldDescriptor(fd, sp.opts.Sep, sp.opts.Subsep)
+				defer subField.release()
+				// incell scalar
+				cell, err := rc.Cell(valColName, sp.IsFieldOptional(subField))
+				if err != nil {
+					return xerrors.WithMessageKV(err, rc.CellDebugKV(valColName)...)
+				}
+				err = sp.parseUnionMessageField(subField, msg, cell.Data)
+				if err != nil {
+					return xerrors.WithMessageKV(err, rc.CellDebugKV(valColName)...)
+				}
+				return nil
+			}()
+			if err != nil {
+				return false, err
+			}
+		}
+	} else {
+		// scalar: not supported yet.
+		return false, xerrors.Errorf("union value (oneof) as scalar type not supported: %s", valueFD.FullName())
+	}
+	msg.Set(valueFD, fieldValue)
+	return present, nil
+}
+
 func (sp *sheetParser) parseScalarField(field *Field, msg protoreflect.Message, rc *book.RowCells, prefix string) (present bool, err error) {
 	if msg.Has(field.fd) {
 		// Only parse if this field is not populated. This means the first
diff --git a/internal/protogen/field_prop.go b/internal/protogen/field_prop.go
index c31a0f0..c33bb44 100644
--- a/internal/protogen/field_prop.go
+++ b/internal/protogen/field_prop.go
@@ -46,6 +46,7 @@ func ExtractListFieldProp(prop *tableaupb.FieldProp, isScalarList bool) *tableau
 		Present:  prop.Present,
 		Optional: prop.Optional,
 		Patch:    prop.Patch,
+		Form:     prop.Form, // for vertical incell union list
 	}
 	if isScalarList {
 		p.Range = prop.Range


Test patch:
diff --git a/test/functest/conf/HorizontalUnionList.json b/test/functest/conf/HorizontalUnionList.json
new file mode 100644
index 0000000..060804f
--- /dev/null
+++ b/test/functest/conf/HorizontalUnionList.json
@@ -0,0 +1,76 @@
+{
+    "taskMap": {
+        "1": {
+            "id": 1,
+            "targetList": [
+                {
+                    "type": "TYPE_PVP",
+                    "pvp": {
+                        "type": 1,
+                        "damage": "10",
+                        "types": [
+                            "FRUIT_TYPE_APPLE",
+                            "FRUIT_TYPE_ORANGE",
+                            "FRUIT_TYPE_BANANA"
+                        ]
+                    }
+                },
+                {
+                    "type": "TYPE_PVE",
+                    "pve": {
+                        "mission": {
+                            "id": 1,
+                            "level": 100,
+                            "damage": "999"
+                        },
+                        "heros": [
+                            1,
+                            2,
+                            3
+                        ],
+                        "dungeons": {
+                            "1": "10",
+                            "2": "20",
+                            "3": "30"
+                        }
+                    }
+                }
+            ]
+        },
+        "2": {
+            "id": 2,
+            "targetList": [
+                {
+                    "type": "TYPE_STORY",
+                    "story": {
+                        "cost": {
+                            "id": 1001,
+                            "num": 10
+                        },
+                        "fruits": {
+                            "1": "FRUIT_TYPE_APPLE",
+                            "2": "FRUIT_TYPE_ORANGE"
+                        },
+                        "flavors": {
+                            "1": {
+                                "key": "FRUIT_FLAVOR_FRAGRANT",
+                                "value": 1
+                            },
+                            "2": {
+                                "key": "FRUIT_FLAVOR_SOUR",
+                                "value": 2
+                            }
+                        }
+                    }
+                },
+                {
+                    "type": "TYPE_SKILL",
+                    "skill": {
+                        "id": 1,
+                        "damage": "2"
+                    }
+                }
+            ]
+        }
+    }
+}
\ No newline at end of file
diff --git a/test/functest/conf/VerticalIncellUnionList.json b/test/functest/conf/VerticalIncellUnionList.json
new file mode 100644
index 0000000..c341cfb
--- /dev/null
+++ b/test/functest/conf/VerticalIncellUnionList.json
@@ -0,0 +1,66 @@
+{
+    "targetList": [
+        {
+            "type": "TYPE_PVP",
+            "pvp": {
+                "type": 1,
+                "damage": "10",
+                "types": [
+                    "FRUIT_TYPE_APPLE",
+                    "FRUIT_TYPE_ORANGE",
+                    "FRUIT_TYPE_BANANA"
+                ]
+            }
+        },
+        {
+            "type": "TYPE_PVE",
+            "pve": {
+                "mission": {
+                    "id": 1,
+                    "level": 100,
+                    "damage": "999"
+                },
+                "heros": [
+                    1,
+                    2,
+                    3
+                ],
+                "dungeons": {
+                    "1": "10",
+                    "2": "20",
+                    "3": "30"
+                }
+            }
+        },
+        {
+            "type": "TYPE_STORY",
+            "story": {
+                "cost": {
+                    "id": 1001,
+                    "num": 10
+                },
+                "fruits": {
+                    "1": "FRUIT_TYPE_APPLE",
+                    "2": "FRUIT_TYPE_ORANGE"
+                },
+                "flavors": {
+                    "1": {
+                        "key": "FRUIT_FLAVOR_FRAGRANT",
+                        "value": 1
+                    },
+                    "2": {
+                        "key": "FRUIT_FLAVOR_SOUR",
+                        "value": 2
+                    }
+                }
+            }
+        },
+        {
+            "type": "TYPE_SKILL",
+            "skill": {
+                "id": 1,
+                "damage": "2"
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/test/functest/conf/VerticalUnionList.json b/test/functest/conf/VerticalUnionList.json
new file mode 100644
index 0000000..c341cfb
--- /dev/null
+++ b/test/functest/conf/VerticalUnionList.json
@@ -0,0 +1,66 @@
+{
+    "targetList": [
+        {
+            "type": "TYPE_PVP",
+            "pvp": {
+                "type": 1,
+                "damage": "10",
+                "types": [
+                    "FRUIT_TYPE_APPLE",
+                    "FRUIT_TYPE_ORANGE",
+                    "FRUIT_TYPE_BANANA"
+                ]
+            }
+        },
+        {
+            "type": "TYPE_PVE",
+            "pve": {
+                "mission": {
+                    "id": 1,
+                    "level": 100,
+                    "damage": "999"
+                },
+                "heros": [
+                    1,
+                    2,
+                    3
+                ],
+                "dungeons": {
+                    "1": "10",
+                    "2": "20",
+                    "3": "30"
+                }
+            }
+        },
+        {
+            "type": "TYPE_STORY",
+            "story": {
+                "cost": {
+                    "id": 1001,
+                    "num": 10
+                },
+                "fruits": {
+                    "1": "FRUIT_TYPE_APPLE",
+                    "2": "FRUIT_TYPE_ORANGE"
+                },
+                "flavors": {
+                    "1": {
+                        "key": "FRUIT_FLAVOR_FRAGRANT",
+                        "value": 1
+                    },
+                    "2": {
+                        "key": "FRUIT_FLAVOR_SOUR",
+                        "value": 2
+                    }
+                }
+            }
+        },
+        {
+            "type": "TYPE_SKILL",
+            "skill": {
+                "id": 1,
+                "damage": "2"
+            }
+        }
+    ]
+}
\ No newline at end of file
diff --git a/test/functest/proto/excel__list__horizontal_list.proto b/test/functest/proto/excel__list__horizontal_list.proto
index c2a7415..23b3cca 100644
--- a/test/functest/proto/excel__list__horizontal_list.proto
+++ b/test/functest/proto/excel__list__horizontal_list.proto
@@ -1,4 +1,4 @@
-// Code generated by tableau (protogen v0.5.0). DO NOT EDIT.
+// Code generated by tableau (protogen v0.6.0). DO NOT EDIT.
 // clang-format off
 
 syntax = "proto3";
@@ -6,6 +6,7 @@ syntax = "proto3";
 package protoconf;
 
 import "common/common.proto";
+import "common/union.proto";
 import "tableau/protobuf/tableau.proto";
 
 option go_package = "github.com/tableauio/tableau/test/functest/protoconf";
@@ -77,3 +78,13 @@ message HorizontalStructList {
     string name = 2 [(tableau.field) = {name:"Name"}];
   }
 }
+
+message HorizontalUnionList {
+  option (tableau.worksheet) = {name:"HorizontalUnionList" namerow:1 typerow:2 noterow:3 datarow:4};
+
+  map<int32, Task> task_map = 1 [(tableau.field) = {key:"ID" layout:LAYOUT_VERTICAL}];
+  message Task {
+    int32 id = 1 [(tableau.field) = {name:"ID"}];
+    repeated union.Target target_list = 2 [(tableau.field) = {name:"Target" layout:LAYOUT_HORIZONTAL}];
+  }
+}
diff --git a/test/functest/proto/excel__list__list.proto b/test/functest/proto/excel__list__list.proto
index ced6a53..3ff32c1 100644
--- a/test/functest/proto/excel__list__list.proto
+++ b/test/functest/proto/excel__list__list.proto
@@ -1,4 +1,4 @@
-// Code generated by tableau (protogen v0.5.0). DO NOT EDIT.
+// Code generated by tableau (protogen v0.6.0). DO NOT EDIT.
 // clang-format off
 
 syntax = "proto3";
diff --git a/test/functest/proto/excel__list__vertical_list.proto b/test/functest/proto/excel__list__vertical_list.proto
index 96b58ce..a4d4e9f 100644
--- a/test/functest/proto/excel__list__vertical_list.proto
+++ b/test/functest/proto/excel__list__vertical_list.proto
@@ -1,4 +1,4 @@
-// Code generated by tableau (protogen v0.5.0). DO NOT EDIT.
+// Code generated by tableau (protogen v0.6.0). DO NOT EDIT.
 // clang-format off
 
 syntax = "proto3";
@@ -6,6 +6,7 @@ syntax = "proto3";
 package protoconf;
 
 import "common/common.proto";
+import "common/union.proto";
 import "tableau/protobuf/tableau.proto";
 
 option go_package = "github.com/tableauio/tableau/test/functest/protoconf";
@@ -27,6 +28,12 @@ message VerticalIncellStructList {
   }
 }
 
+message VerticalIncellUnionList {
+  option (tableau.worksheet) = {name:"VerticalIncellUnionList" namerow:1 typerow:2 noterow:3 datarow:4};
+
+  repeated union.Target target_list = 1 [(tableau.field) = {name:"Target" layout:LAYOUT_VERTICAL span:SPAN_INNER_CELL prop:{form:FORM_TEXT}}];
+}
+
 message VerticalPredefinedStructList {
   option (tableau.worksheet) = {name:"VerticalPredefinedStructList" namerow:1 typerow:2 noterow:3 datarow:4};
 
@@ -43,3 +50,9 @@ message VerticalStructList {
     string desc = 3 [(tableau.field) = {name:"Desc"}];
   }
 }
+
+message VerticalUnionList {
+  option (tableau.worksheet) = {name:"VerticalUnionList" namerow:1 typerow:2 noterow:3 datarow:4};
+
+  repeated union.Target target_list = 1 [(tableau.field) = {layout:LAYOUT_VERTICAL}];
+}
diff --git a/test/functest/testdata/excel/list/HorizontalList#HorizontalUnionList.csv b/test/functest/testdata/excel/list/HorizontalList#HorizontalUnionList.csv
new file mode 100644
index 0000000..757292e
--- /dev/null
+++ b/test/functest/testdata/excel/list/HorizontalList#HorizontalUnionList.csv
@@ -0,0 +1,5 @@
+ID,Target1Type,Target1Field1,Target1Field2,Target1Field3,Target2Type,Target2Field1,Target2Field2,Target2Field3
+"map<int32, Task>",[union.Target]enum<union.Target.Type>,union,union,union,enum<union.Target.Type>,union,union,union
+ID,Target's type,Target's field1,Target's field2,Target's field3,Target's type,Target's field1,Target's field2,Target's field3
+1,PVP,1,10,"Apple,Orange,Banana",PVE,"1,100,999","1,2,3","1:10,2:20,3:30"
+2,Story,"1001,10","1:Apple,2:Orange","Fragrant:1,Sour:2",Skill,1,2,
diff --git a/test/functest/testdata/excel/list/VerticalList#VerticalIncellUnionList.csv b/test/functest/testdata/excel/list/VerticalList#VerticalIncellUnionList.csv
new file mode 100644
index 0000000..e69aa54
--- /dev/null
+++ b/test/functest/testdata/excel/list/VerticalList#VerticalIncellUnionList.csv
@@ -0,0 +1,7 @@
+Target
+[]{union.Target}|{form:FORM_TEXT}
+Target's info
+type:TYPE_PVP pvp:{type:1 damage:10 types:FRUIT_TYPE_APPLE types:FRUIT_TYPE_ORANGE types:FRUIT_TYPE_BANANA}
+type:TYPE_PVE pve:{mission:{id:1 level:100 damage:999} heros:1 heros:2 heros:3 dungeons:{key:1 value:10} dungeons:{key:2 value:20} dungeons:{key:3 value:30}}
+type:TYPE_STORY story:{cost:{id:1001 num:10} fruits:{key:1 value:FRUIT_TYPE_APPLE} fruits:{key:2 value:FRUIT_TYPE_ORANGE} flavors:{key:1 value:{key:FRUIT_FLAVOR_FRAGRANT value:1}} flavors:{key:2 value:{key:FRUIT_FLAVOR_SOUR value:2}}}
+type:TYPE_SKILL skill:{id:1 damage:2}
diff --git a/test/functest/testdata/excel/list/VerticalList#VerticalUnionList.csv b/test/functest/testdata/excel/list/VerticalList#VerticalUnionList.csv
new file mode 100644
index 0000000..b84f141
--- /dev/null
+++ b/test/functest/testdata/excel/list/VerticalList#VerticalUnionList.csv
@@ -0,0 +1,7 @@
+Type,Field1,Field2,Field3," Progress"
+[union.Target]enum<union.Target.Type>,union,union,union," int32"
+Target's type,Target's field1,Target's field2,Target's field3," Progress"
+PVP,1,10,"Apple,Orange,Banana",3
+PVE,"1,100,999","1,2,3","1:10,2:20,3:30",10
+Story,"1001,10","1:Apple,2:Orange","Fragrant:1,Sour:2",10
+Skill,1,2,,8