Beispiel #1
0
def chain_zones(olds):
    """
     combines anded zones creating an a single zone
     :param olds: the current list of tokens
     :return: list of tokens with zones chained
    """
    news = []
    skip = 0

    for i,tkn in enumerate(olds):
        # skip already processed tokens
        if skip:
            skip -= 1
            continue

        if tag.is_zone(tkn):
            # 'and has two cases (1) zone1 and zone2 (2) zone1, zone2, and zone3
            # possible to get an (3) and/or ('op<⊕>')
            try:
                # case 1 and case 3
                if olds[i+1] in ['and','op<⊕>'] and tag.is_zone(olds[i+2]):
                    # untag both zones
                    z1 = tag.untag(tkn)[1]
                    z2 = tag.untag(olds[i+2])[1]

                    # determine the operator to use
                    op = mtgl.AND if olds[i+1] == 'and' else mtgl.AOR
                    news.append(tag.retag('zn',z1+op+z2,{}))
                    skip = 2
                    continue

                # case 2
                case2 = [mtgl.CMA,tag.is_zone,mtgl.CMA,'and',tag.is_zone]
                if ll.matchl(olds,case2,stop=1) == 1:
                    zs = [
                        tag.untag(tkn)[1],
                        tag.untag(olds[i+2])[1],
                        tag.untag(olds[i+5])[1]
                    ]
                    news.append(tag.retag('zn',mtgl.AND.join(zs),{}))
                    skip = 5
                    continue
            except IndexError:
                pass
        news.append(tkn)
    return news
Beispiel #2
0
def _comma_read_ahead_(tkns):
    """
     reads ahead to determine how a comma will be processed in the current chain
    :param tkns: the list of tokens
    :return: mtgl.AND if it is a list of and'ed characteristics, mtgl.OR if it
     is a list of or'ed characteristcs or ',' if neither
    """
    # read ahead until we find 'or' or 'and' or a non-characteristic
    for i,tkn in enumerate(tkns):
        if tkn == 'or':
            if ll.matchl(tkns[i:],[tag.is_mtg_char,tag.is_mtg_obj],stop=1) == 1:
                return mtgl.OR
            else: return mtgl.CMA
        elif tkn == 'and': return mtgl.AND
        elif tkn == ',': continue # skip the commas
        elif not tag.is_mtg_char(tkn): return mtgl.CMA
    return mtgl.CMA
Beispiel #3
0
def parse(txt):
    """
    parses the tokenized text of card into mtgl
    :param txt: tagged and tokenized text
    returns the parsed oracle text
    """
    ptxt = []
    for i,line in enumerate(txt):
        try:
            # check for reference to 'draft' in the line. if so, drop the line
            if ll.matchl(line,[re_draft]) > -1: continue
            line = rectify(line)
            line = chain(line)
            line = chain_zones(line)
            line = group(line)
            line = add_hanging(line)
            line = merge_possessive(line)
            ptxt.append(line)
        except Exception as e: # generic catchall for debugging purposes
            raise mtgl.MTGLException("Parse failure {} at line {}.".format(type(e),i))
    return ptxt
Beispiel #4
0
def merge_possessive(olds):
    """
     combines possesive with corresponding object (if there is one) and
     possessive with corresponding zones (if there is one)
    :param olds: current list of tokens
    :return: the updated list of tokens
    """
    news = []
    skip = 0
    for i,tkn in enumerate(olds):
        # skip tokens if necessary
        if skip:
            skip -= 1
            continue

        if tag.is_mtg_obj(tkn):
            # after chaining/grouping, objects that have possesives
            # will be follwed by xp<player> xc<[not]control|own>
            if ll.matchl(olds[i:],[tag.is_player,is_possessive],stop=1) == 1:
                ot,ov,op = tag.untag(tkn)          # object tag
                pt,pv,pp = tag.untag(olds[i+1])    # player tag
                _,v,_ = tag.untag(olds[i+2])       # possessive tag

                # is the possessive negated? if so, strip it out
                neg = mtgl.NOT in v
                if neg: v = v[1:]

                # determine property
                prop = 'controller' if v == 'control' else 'owner'

                # process 'you' and player/opponent differently
                if pv == 'you':
                    if neg: pv = 'opponent' # make opponent if negated
                    op[prop] = pv
                    news.append(tag.retag(ot,ov,op))
                else:
                    # if there is a quantifier, AND it with player
                    if not 'quantifier' in pp: val = pv
                    else: val = pp['quantifier'] + mtgl.AND + pv

                    # does the player have a status?
                    # TODO: 1. haven't seen any players with a status and
                    #  quantifier 2. several ways to add status, could use
                    #  AND or ARW or wrap in parenthesis etc - which is best?
                    if 'status' in pp: val = pp['status'] + mtgl.AND + val
                    op[prop] = val
                    news.append(tag.retag(ot,ov,op))

                # skip the merged tokens
                skip = 2
                continue
        elif tag.is_player(tkn):
            # after chaining/grouping, players that possess a zone will be
            # followed by the zone
            try:
                if tag.is_zone(olds[i+1]):
                    pt,pv,pps = tag.untag(tkn)
                    zt,zv,_ = tag.untag(olds[i+1]) # shouldnt be a prop-list

                    # check for quantifiers in the plaer
                    if not 'quantifier' in pps: val = pv
                    else: val = pps['quantifier'] + mtgl.AND + pv

                    # check for 'of' in the player (looking for xp<owner of=it>
                    if 'of' in pps: val = pps['of'] + mtgl.ARW + val

                    # add the zone to news
                    news.append(tag.retag(zt,zv,{'player':val}))
                    skip = 1 # skip the next token (the zone)

                    continue
            except IndexError: pass

        # no possessive, just append the token
        news.append(tkn)
    return news
Beispiel #5
0
def add_hanging(olds):
    """
     adds any hanging meta characterisitics, status to an object and the seq.
     pr<in> zn<ZONE> to objects
    :param olds: the current mtgl
    :return: updated mtgl w/ hanging meta characteristics, status added to objs
    """
    news = []
    skip = 0

    for i,tkn in enumerate(olds):
        # skip already processed tokens
        if skip:
            skip -= 1
            continue

        if tkn == 'pr<with>' or tkn == 'pr<without>':
            # if the last token added is an object untag it
            if news and tag.is_mtg_obj(news[-1]):
                ot,ov,ops = tag.untag(news[-1])

                # two possibilities following the with/without
                #  keywords - the next 'phrase' is "kw<KW>" or "kw<KW> and kw<KW>"
                #   we assume there won't be 'or'ed keywords or more than 2
                #  NOTE: we have to check the double kw first
                #  meta - the next 'phrase' is ch<META> op<OP> nu<NU>
                skw = [tag.is_keyword]
                dkw = [tag.is_keyword,'and',tag.is_keyword]
                meta = [tag.is_meta_char,tag.is_operator,tag.is_number]

                if ll.matchl(olds[i+1:],dkw,stop=0) == 0:
                    assert(tkn == 'pr<with>') # shouldn't have a neg in this case
                    # pull out both keywords and combine
                    kw = mtgl.AND.join(
                        [tag.untag(olds[i+1])[1],tag.untag(olds[i+3])[1]]
                    )

                    # add the keyword to the objects meta or create meta parameter
                    if 'meta' in ops: ops['meta'] += mtgl.AND + kw
                    else: ops['meta'] = kw

                    # retags and appends the popped object)
                    news.pop()
                    news.append(tag.retag(ot,ov,ops))
                    skip = 3
                    continue
                elif ll.matchl(olds[i+1:],skw,stop=0) == 0:
                    # single keyword, extract it, & check if should be negative
                    kw = tag.untag(olds[i+1])[1]
                    neg = '' if tkn == 'pr<with>' else mtgl.NOT

                    # add the keyword to the objects meta or create meta parameter
                    if 'meta' in ops: ops['meta'] += mtgl.AND + neg + kw
                    else: ops['meta'] = kw

                    # retags and appends the popped object)
                    news.pop()
                    news.append(tag.retag(ot,ov,ops))
                    skip = 1
                    continue
                elif ll.matchl(olds[i+1],meta,stop=0) == 0:
                    # 2) meta op number
                    mv = tag.untag(olds[i+1])[1] # untag the meta characteristic
                    ov = tag.untag(olds[i+2])[1] # and the operator
                    nv= tag.untag(olds[i+3])[1] # and the number

                    # check if obj already has a meta parameter
                    if 'meta' in ops: ops['meta'] += mtgl.AND + mv + ov + nv
                    else: ops['meta'] = mv + ov + nv
                    # retags and appends the popped object)
                    news.pop()
                    news.append(tag.retag(ot,ov,ops))
                    skip = 3
                    continue

                # if nothing happens, the preposition will fall through and
                # be added
        elif tag.is_state(tkn):
            # untag the last token added and verify it's an object
            try:
                t,v,p = tag.untag(news[-1])
                if t == 'ob':
                    # untag the current, saving the 'status' value
                    ss = [tag.untag(tkn)[1]]
                    op = mtgl.AND

                    # read ahead while and/or and status are present
                    j = i+1
                    while True:
                        try:
                            if tag.is_state(olds[j]):
                                ss.append(tag.untag(olds[j])[1])
                                skip += 1
                                j += 1
                            elif olds[j] == 'or':
                                op = mtgl.OR
                                skip += 1
                                j += 1
                            elif olds[j] == 'and':
                                skip += 1
                                j += 1
                            else: break
                        except IndexError:
                            break

                    # add the status(es)
                    if 'status' in p: p['status'] += op.join(ss)
                    else: p['status'] = op.join(ss)
                    news.pop()
                    news.append(tag.retag(t,v,p))
                    continue
            except mtgl.MTGLTagException: pass
        elif tkn == 'xc<devotion>':
            # look for devotion 'clauses' - devotion will be of the form:
            #  xp<PLY> xc<devotion> pr<to> ob<card characteristics=CH>
            # and although currently worded only for xp<you>, we will consider
            # any player as viable
            try:
                # last added will be xp, next 2 will be pr<to> and ch<...>
                if tag.is_player(news[-1]) and olds[i+1] == 'pr<to>' and\
                        tag.is_mtg_obj(olds[i+2]):
                    # for devotion, player should be simple & characteristics
                    # should be a color(s)
                    ply = tag.untag(news.pop())[1] # should always be 'you'
                    ps = tag.untag(olds[i+2])[2]
                    news.append(
                        tag.retag('xp',ply,{'devotion':ps['characteristics']})
                    )
                    skip = 2
                    continue
            except mtgl.MTGLTagException: pass
            except (IndexError,ValueError): pass
        elif tag.is_zone(tkn):
            # TODO: make sure this is working as expected
            # check if we have a phrase ob<OBJECT> pr<in> zn<ZONE>
            try:
                if tag.is_mtg_obj(news[-2]) and news[-1] == 'pr<in>':
                    # untag the zone and get owner or quantifer
                    t,v,ps = tag.untag(tkn)
                    assert(not('quantifier' in ps and 'player' in ps))
                    p = None
                    if 'quantifier' in ps: p = ps['quantifier']
                    elif 'player' in ps: p = ps['player']

                    # untag the existing object (make it zone->player or
                    # zone->quantifier) and add to that object
                    news.pop() # pop the preposition
                    ot,ov,ops = tag.untag(news.pop())

                    if p: ops['zone'] = '{}{}{}'.format(v,mtgl.ARW,p)
                    else: ops['zone'] = v

                    # add the ob back to news
                    news.append(tag.retag(ot,ov,ops))
                    continue
            except IndexError: pass
        news.append(tkn)
    return news
Beispiel #6
0
def rectify(olds):
    """
     fixes several issues.
      1. token (non)token is the only top-level object from rule 109 but acts
        like a a chracteristic.
      2. or'ed and and'ed objects (primarily/only spells or abilities) need to be
       combined
      3. double tagged an mis-tagged require context to be fixed
      4. combines phrases like nu<1> or more or nu<10> or less into a single
       nu tag
      5. miscellaneous fixes that assist in parsing and graphing
    :param olds: the current list of tokens
    :return: tokens with subsequent entities merged
    """
    # 5. before enumerating the tokens we'll make some list replacements

    # a. combined or'ed activated, triggered status (see Stifle
    olds = ll.replacel(
        olds,['xs<activated>','or','xs<triggered>'],['xs<activated∨triggered>']
    )

    # b. combine xo<it> xp<...> and xp<their> xp<...>
    i = ll.matchl(olds,[ll.ors('xo<it>','xp<their>'),tag.is_player])
    while i > -1:
        t,v,ps = tag.untag(olds[i+1])
        ps['of'] = 'it' if olds[i] == 'xo<it>' else 'them' # add of=? to proplist
        olds[i:i+2] = [tag.retag(t,v,ps)]                 # retag
        i = ll.matchl(olds,[ll.ors('xo<it>', 'xp<their>'),tag.is_player])

    news = []
    skip = 0
    for i,tkn in enumerate(olds):
        # skip any tokens that have already been processed
        if skip:
            skip -= 1
            continue

        # check 1 and 2 here if current token is an object
        if tag.is_mtg_obj(tkn):
            # (1) move (non)token to right of characteristics
            if 'token' in tkn and ll.matchl(olds[i:],[tag.is_mtg_char],stop=1) == 1:
                j = i+1
                while ll.matchl(olds[j:],[tag.is_mtg_char],stop=0) == 0:
                    news.append(olds[j])
                    j += 1
                skip = (j-i)-1
                news.append(tkn)
                continue

            # (2) make two and'ed/or'ed 'consecutive' singleton objs one
            if ll.matchl(olds[i:],[tag.is_coordinator,tag.is_mtg_obj],stop=1) == 1:
                # see Glyph Keeper (spell or ability)
                t,v,p = tag.untag(olds[i+2])
                if not p: # don't join anything with a prop list
                    if olds[i+1] == 'or': op = mtgl.OR
                    elif olds[i+1] == 'and': op = mtgl.AND
                    else: op = mtgl.AOR
                    news.append(tag.retag(
                        t,"{}{}{}".format(tag.untag(tkn)[1],op,v),{})
                    )
                    skip = 2
                    continue

        # (3) determine if
        # a) 'copy' is always tagged as an ob because lituus actions check for an
        # existing tag before tagging. If it's followed by an object
        # or quantifier, then its an action (lituus) otherwise its an object
        if tkn == 'ob<copy>':
            # copy quantifier spell|ability, copy quantifier instant|sorcery

            if ll.matchl(olds[i:],[tag.is_quantifier,tag.is_mtg_obj],stop=1) == 1:
                news.append('xa<copy>')
            elif ll.matchl(olds[i:],[tag.is_quantifier,tag.is_mtg_char],stop=1) == 1:
                news.append('xa<copy>')
            elif ll.matchl(olds[i:],['xo<it>'],stop=1) == 1:
                # i.e. copy it
                news.append('xa<copy>')
            else:
                # last check, looking for a phrase "copy the ob<...>" where the
                # value of ob is spell. Also looking for one of two phrases
                # in the already added tokens 'you may' or 'spell,'
                j = ll.matchl(news,['xp<you>','cn<may>'])
                k = ll.matchl(news,['ob<spell>',mtgl.CMA])
                if j > -1 and i-j == 2: news.append('xa<copy>')
                elif k > -1 and i-k == 2: news.append('xa<copy>')
                elif ll.matchl(olds[i:],['xq<the>',tag.is_mtg_obj],stop=1) == 1:
                    _,v,ps = tag.untag(olds[i+2])
                    if v == 'spell': news.append('xa<copy>')
                    else: news.append('ob<copy>')
                else:
                    news.append('ob<copy>')
            continue

        # b) target (114)
        if tkn == 'xq<target>':
            # target could be a:
            #  1. action 'that targets one or more creatures'
            #  2. quantifier 'destroy target creature'
            #  3. object 'can't be the target of spells or abilities'
            # Although as an object, it presence in non-reminder text is limtited

            # NOTE: by using the slice "-n:" we avoid index errors

            if ll.matchl(news[-3:],['cn<cannot>','be','xq<the>']) == 0:
                # check conversion to object first. The full phrase is "cannot be
                # the target of..."  but we can check news for "cannot be the".
                news.append('xo<target>')
            elif ll.matchl(news[-2:],[ll.ors('becomes','change'),'xq<the>']) == 0:
                # another conversion to object requires checking for 'becomes' or
                # 'change' prior to the
                news.append('xo<target>')
            elif ll.matchl(news[-3:],['pr<with>','a','single']) == 0:
                # look for "with a single"
                # TODO: are there cases where 'single' is not present or different
                news.append('xo<target>')
            elif ll.matchl(news[-1:],[ll.ors('new','xa<any>')]) == 0:
                # last object check if the preceding word is new
                news.append('xo<target>')
            elif ll.matchl(news[-1:],[ll.ors('xq<that>','could','ob<copy>','must')]) == 0:
                # determine if 'target' is an action, the easiest is to check the
                # preceding tokens if we find, 'that', 'could', 'copy', must it's
                # an action NOTE: based on the assumption that rectify has correctly
                # fixed copy tokens
                news.append('xa<target>')
            else:
                news.append('xq<target>')
            continue

        # c) ka<exile> is an action or a zone. If its preceded by a preposition
        # change it to zone, otherwise leave it alone
        if tkn == 'ka<exile>':
            try:
                if tag.is_preposition(news[-1]): news.append('zn<exile>')
                else: news.append(tkn)
            except IndexError:
                news.append(tkn)
            continue

        # d) ka<counter> is a keyword action or refers to a counter. In counter
        # spells, the word counter is followed by a quantifier. Additionally,
        # the phrase cannot be countered (cn<cannot>, be, ka<counter>) referes
        # to the keyword action
        if tkn == 'ka<counter>':
            try:
                if tag.is_quantifier(olds[i+1]) or\
                        news[i-2:i] == ['cn<cannot>','be']:
                    news.append(tkn)
                else: news.append('xo<ctr>')
            except IndexError:
                news.append('xo<ctr>')
            continue

        # e) ka<vote> vote appears often in cards relating to voting. We could
        # take the "strict" interpretation but since we don't yet do that for
        # other keyword-actions we will change cases of the form
        # "tied for the most votes", "gets more votes", "vote is tied"
        # TODO: it doesn't catch for each x vote see Expropriate
        if tkn == 'ka<vote>':
            try:
                if news and news[-1] == 'most': news.append('vote')
                elif news and news[-1] == 'more': news.append('vote')
                elif olds[i+1:i+3] == ['is','tied']: news.append('vote')
                else: news.append(tkn)
            except IndexError:
                news.append('vote')
            continue

        # f) activate/trigger mtgl and tagger do remove conjugations from activated
        # (& triggered) so we end up with both 'activate' and 'activated' where
        # activated is tagged as a status. see Cursed Totem. Here the first activated
        # is a status but the last activated should be rectified to an action
        if tkn == 'xs<activated>' or tkn == 'xs<triggered>':
            # create the tag and value if we have to change the token
            if tkn == 'xs<activated>':
                t = 'ka' # activate is recognized in the rules as a KWA
                v = 'activate'
            else:
                t = 'xa' # trigger is not recognized
                v = 'trigger'

            # if the next token is an object we have a status otherwise, we should
            # retag as an action
            if ll.matchl(olds[i:],[tag.is_mtg_obj],stop=1) != 1:
                news.append(tag.retag(t,v,{}))
            else: news.append(tkn)
            continue

        # (4) combine numeric phrases
        if tag.is_number(tkn):
            # check for 'or' followed by 'more' or 'less'
            try:
                op = None
                if ll.matchl(olds[i+1:],['or','less']) == 0: op = mtgl.LE
                elif ll.matchl(olds[i+1:],['or','more']) == 0: op = mtgl.GE
                if op:
                    news.append("nu<{}{}>".format(op,tag.untag(tkn)[1]))
                    skip = 2
                    continue
            except IndexError: pass

        # append the token
        news.append(tkn)

    return news
Beispiel #7
0
def _chain_pt_(ns):
    if ll.matchl(ns,[tag.is_lituus_act],start=len(ns)-1) > -1: return False
    if ll.matchl(ns,['choice','of']) > -1: return False
    return True
Beispiel #8
0
def chain(olds):
    """
     combines charcteristics belonging to an object, creating an implied object
     if necessary - characteristics will occur prior to the object (if there is one)
     Also takes care of card name card like phrases (see Hanweir Battlements)
     :param olds: the current list of tokens
     :return: chained list of tokens

     NOTE:
      Perceived "rules" regarding and'ed, or'ed sequences of characterisitics
      along with uses of commas in MTG oracle text are:
      1. chained characteristics are always either all 'anded' or all 'ored'
      2. chained characteristics will precede the object (if there is one)
       a. token, while an object (109), acts as a characterisitic
      3. 'and' chains are (almost always) space delimited
       a. any comma signifies distinct objects vice a sequence
       b. two items may be 'and'ed if there are no commas
       c. two characteristics may be comma 'and'ed if they are followed by a
          object i.e. type, type obj and the types are negated
      4. 'or' chains will contain the word 'or' and will be comma delimited
       for chains of more than 2 characterisitcs
    """
    news = []              # new tokens
    cs = []                # list of characterisitcs
    pt = None              # power/toughness
    op = mtgl.AND          # the chain operator
    ti = 'ob'              # tag-id
    tp = "characteristics" # prop-list key

    for i,tkn in enumerate(olds):
        if tag.is_mtg_char(tkn):
            # only chain meta characteristics if they are p/t & meet specific criteria
            _,v,p = tag.untag(tkn)
            if not tag.is_meta_char(tkn): cs.append(v)
            else:
                # if we find a lituus action i.e. gets or the word of, do
                # not add
                if v == 'p/t' and _chain_pt_(news): pt = p['val']
                else:
                    # close out the chain if its open
                    if cs:
                        pl = {tp:op.join(cs)}
                        if pt: pl['meta'] = 'p/t' + mtgl.EQ + pt
                        news.append(tag.retag(ti,_implied_obj_(cs),pl))
                        op = mtgl.AND
                        cs = []
                        pt = None

                    # then add the token
                    news.append(tkn)
        else:
            # current token is not a characterisitic. If we're not in a chain,
            # append the token otherwise determine how to treat the token
            if not cs: news.append(tkn) # not in chain, append the token
            else:
                # we are in a current chain
                if tkn == ',':
                    nop = _comma_read_ahead_(olds[i:])
                    # see grapher->collate, the rules for determining if objects
                    # should be chained is cumbersome. we'll use basic hueristics
                    # here and allow the grapher to apply the additional rules
                    if nop == mtgl.AND or nop == ',': # distinct objects
                        pl = {tp:op.join(cs)}
                        if pt: pl['meta'] = 'p/t'+mtgl.EQ+pt
                        news.append(tag.retag(ti,_implied_obj_(cs),pl))
                        news.append(',')
                        op = mtgl.AND
                        cs = []
                        pt = None
                    else: op = mtgl.OR
                elif tkn == 'or': op = mtgl.OR
                elif tkn == 'and':
                    # have to read ahead. if next token is not a characteristic,
                    # need to close out the current chain, and append the 'and'
                    # otherwise just set the op and continue
                    if _chained_and_(i,olds): op = mtgl.AND
                    else:
                        pl = {tp:op.join(cs)}
                        if pt: pl['meta'] = 'p/t'+mtgl.EQ+pt
                        news.append(tag.retag(ti,_implied_obj_(cs),pl))
                        news.append(tkn)
                        op = mtgl.AND
                        cs = []
                        pt = None
                elif tag.is_mtg_obj(tkn):
                    t,v,p = tag.untag(tkn)              # untag it
                    p[tp] = op.join(cs)                  # update it's prop list
                    if pt: p['meta'] = 'p/t'+mtgl.EQ+pt  # & meta if present
                    news.append(tag.retag(t,v,p))       # retag and append
                    op = mtgl.AND                        # and reset the chain
                    cs = []
                    pt = None
                else:
                    # no object, end the chain and append the implied object
                    pl = {tp:op.join(cs)}
                    if pt: pl['meta'] = 'p/t'+mtgl.EQ+pt
                    news.append(tag.retag(ti,_implied_obj_(cs),pl))
                    news.append(tkn)
                    op = mtgl.AND
                    cs = []
                    pt = None

    # if unbuilt chain remains, build/append the object then return the new tkns
    # p/t should never be present if other characteristics are not present
    if cs:
        pl = {tp:mtgl.AND.join(cs)}
        if pt: pl['meta'] = 'p/t'+mtgl.EQ+pt
        news.append(tag.retag(ti,_implied_obj_(cs),pl))

    # combine card named card and variants. Looking for ob<card> name ob<card...>
    # this phrase will be combined into a single tagged token. This is performed
    # here because characteristics will have been tagged as an object (Hanweir
    # Battlements' 'creature named ...' at this point is now ob<permanent
    # characteristics=creature> named ..." which is easier to process. Additionaly,
    # if not done here it will be much harder to later
    cnc = [tag.is_mtg_obj,'named',tag.is_mtg_obj]
    i = ll.matchl(news,cnc)
    while i > -1:
        # the first token should be a permanent with a type in the characteristics
        # attribute. if not, it should be ob<card>
        t1,v1,ps1 = tag.untag(news[i])
        ctype = ps1['characteristics'] if 'characteristics' in ps1 else None

        # the last token should be a ob<card...> with a ref attribute
        t2,v2,ps2 = tag.untag(news[i+2])

        # create a new token using the val from the first, the prop list from
        # the second (adding characterstics from the first one)
        if ctype:
            # shouldn't be chcaracteristics in the 2nd object but just in case
            try:
                ps2['characteristics'] += mtgl.AND + ctype
            except KeyError:
                ps2['characteristics'] = ctype
        news[i:i+3] = [tag.retag(t1,v1,ps2)]

        # check for more
        i = ll.matchl(news,cnc)

    return news