极客挑战赛-笔记一
最近参加了一场极客挑战赛,挺有意思的,准备简单写下笔记。整个比赛是以游戏形式让大家参与的,一共四个,一开始的时候,我以为真的可以靠纯手打,去进行通关,后来发现,想得太天真了。四个游戏,都是需要通过一些编程去解决的,我过了大部分关卡,不过有些是靠的运气,看完官方的题解后,又有了新的认知。
第一个游戏是类似回合制RPG游戏,类似:
画面没这么好哈,版权原因不能用游戏原图,总之有一个行动条,到了时间就可以行动,英雄(就1个)和怪物(一场战斗也就1个)互相攻击,主角在一个地下城堡,总共24层,一层一层往下冲关,每层会有一个宝箱,每次进入下一层,地图都是随机选择的,地图模式有简单、困难和BOSS,每经过8层,地图中怪兽会升级。
英雄是有技能池的,选择什么技能攻击怪兽,是可以进行策略选择的。
英雄的数据:
hero = {
"Attack": 30,
"Hp": 200,
"Mp": 200,
"Skills": {
"skill_0": {
"Cost_mp": 0,
"Power": 1
},
"skill_1": {
"Cost_mp": 10,
"Power": 1.5
},
"skill_2": {
"Cost_mp": 20,
"Power": 2
},
"skill_3": {
"Cost_mp": 30,
"Power": 2.5
}
},
"Speed": 30
}
典型的怪物数据:
"Monsters":[
{
"Attack":30,
"Hp":40,
"Speed":25
},
{
"Attack":20,
"Hp":50,
"Speed":20
}
]
上面的Monsters数据是简单地图的,字段都还比较好理解哈,不多做解释了。
游戏还有一个设定,如果是非8、16、24层,每层都有一个宝箱,这个宝箱是随机开出来奖励或者惩罚,一共有4种情况:
- empty_box,空箱子;
- add_hp_mp,加血又加魔法值,Hp+10且Mp+50;
- del_hp,扣10血;
- 升级属性,可以选择Attack+10,Speed+1,Hp+100,Mp+200;
是不是感觉这些写出来变量都有点多了,而且都是靠自己抓包,分析出来的,其实玩的时候,真的需要一点点琢磨。然后我通过手打1~8层,发现了一些规律:
- 如果英雄和怪兽Speed一样,英雄是占先的,也就是速度这块,玩家是占点便宜的;
- 通过抓包发现,整个游戏和后端交互的协议就三条吧,new_level(重开游戏),next_layer(进入下一层),和open_box(开宝箱);
new_level请求和回包:
{token: "YOUR_TOKEN"}
{"code":0,"data":{"hero":{"Attack":30,"Hp":200,"Mp":200,"Skills":{"skill_0":{"Cost_mp":0,"Power":1},"skill_1":{"Cost_mp":10,"Power":1.5},"skill_2":{"Cost_mp":20,"Power":2},"skill_3":{"Cost_mp":30,"Power":2.5}},"Speed":30},"star_num":3},"message":""}
open_box请求和回包:
{"Layer_idx":1,"token":"YOUR_TOKEN"}
{"code":0,"data":{"box":{"BoxType":"add_hp_mp","Value":[30,50]}},"message":""}
next_layer请求和回包:
{"Layer_idx":1,"Choice":"left","Layer_ops":[["start_battle",0],["battle_op",170,200,[["Hero","skill_0"],["Monster","skill_0"],["Hero","skill_0"]]],["start_battle",1],["battle_op",150,190,[["Hero","skill_1"],["Monster","skill_0"],["Hero","skill_0"]]],["open_box"]],"token":"06xyzzou0074CFBD948B14E238FB24BC"}
{"code":0,"data":{"layer":{"Layer_idx":2,"Map_mode":"boss_map","Monsters":[{"Attack":30,"Hp":100,"Speed":40}]}},"message":""}
- 在客户端清理了一层的怪物后,可以选择去下一层的两扇门(left or right),这时候客户端会发送后端,玩家在这一层执行的所有动作序列(看上面的示例),然后后端会根据客户端上传的序列,在后台重放,看看动作序列是否合法,合法就可以进入下一层;
上面这些都是我通过手打1~8层,发现的一些规律,然后我进入第9层,发现怪物确实变强了些,那么通过上面的观察,我第一个想法是每次对怪物的击打,都要经过最优策略的选择,这个最优策略我想了下,每次打怪物的时候,我最终击败他时,保证Hp+Mp是最大的,这个算法我通过bsf,广度优先搜索,大概写了3个小时(也debug了一会),终于写出来了,其实最开始,我天真的以为每次只要固定动作序列即可,什么意思,就是我手动计算一遍简单、困难和boss的地图,会产生什么合法的对列,然后直接发几个构造好的next_layer的请求即可,后来发现,果然是天真了,极客挑战赛,不可能是这么蠢的解法(当然我觉得也是有可能能通过的,毕竟怪物属性值不是随机的,就那么几种)。但是作为程序员,怎么能都通过手动算呢,所以我就写了一个和怪物战斗时的最优策略选择算法。代码不贴了,思路是这样的:
- 通过计算总共可能的最多攻击次数,英雄最多的攻击次数 = 怪物血量/英雄普通攻击(不耗魔的那个),怪物攻击的最多次数 = 英雄血量/怪物攻击力,选较小的次数保留下来;
- 在最小的次数规定的时间内,把英雄和怪物可以行动的时间点插入到一个数组(理论上速度够快的一方,其实可以“套圈”,当然实际游戏数据中未出现这种数据);
- 把2计算得到的数组进行排序,得到一个按时间排序好的有序时间数组,数组元素记录了轮到哪一方行动,这里有一个小tip,因为上面发现了英雄和怪物速度一样的时候,英雄占先,所以偷偷给怪物行动时间加一个小的偏移量,这样排序后的结果保证了相同速度时,英雄在怪物前行动;
- 按上述有序数组,挨个做动作,如果怪物动,就一种行动,用他的普通攻击攻击英雄,如果英雄动,英雄有4种行动可选,挨个试,合法状态继续bfs;
- 直到怪物死掉,这个动作序列是有效的,保存下来(不仅保存动作,也保存英雄剩余的Hp和Mp);
- 得到所有可能的有效动作序列后(有效指的是英雄杀死了怪物),看看哪一个序列Hp+Mp最大,执行这个动作序列;
宝箱我就每层都打开,这里也有策略,因为有一个宝箱是提升英雄属性,我一开始是Hp<1.5Mp,加Hp,Mp<150加Mp,Hp和Mp都健康,加Attack,后来发现这个策略不行,第二阶段(9~16层),里面有个怪物的Speed是31,英雄默认的Speed是30,这不欺负人吗?
果断在1~8层有机会先Speed+1,然后尽可能在血量健康状态下加Attack,这样跑了4个小时,被我跑到了接近16层,但是后面还是会挂,那怎么办呢,嗯,我想到了,启动一个后台进程,一直跑,嘻嘻。。。
一值跑的过程很煎熬,因为不知道这么做是不是能打穿24层的地堡,我当时觉得组委会肯定不是这么设计题目的,后来证明这确实是暴力过题的一种方法,跑了20+小时,我查看了日志,发现一个问题,有些运气爆棚的时候,确实可以避开扣血宝箱,而且尽可能加属性,加Hp和Mp,地图也是大多数是简单的,达到过21层,在22层时,遇到一个攻击400,Speed超级快的Boss,基本没有还手之力,我突然郁闷了。。。
在内部的讨论群,我发现很多人都说自己就是暴力通过的,得靠人品,所以又等了接近一天,皇天不负有心人,真的通过啦,那一刻,我快乐得像个孩子,好开心啊~
但是故事还没结束,官方题解下来后,我发现自己漏了一个(或者不是漏了,是根本没往那方面想)点,在第七层,有一段代码会展示给选手,大概是这样的:
func ProcessOneUser(nowLayerId int, queryCMD string, choice string) string {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for {
if queryCMD == "open_box" {
if nowLayerId == 8 || nowLayerId ==16 || nowLayerId == 24 {
return "new_star"
} else if nowLayerId == 7 {
return "secret_code"
} else {
switch r.Int63() % 4 {
case 0:
return "empty_box"
case 1:
return "add_hp_mp"
case 2:
return "del_hp"
case 3:
return "add_skill"
}
}
} else if queryCMD == "next_layer" {
newLayerId := nowLayerId + 1
// rest_map 休息房,没有怪物
// easy_map 简单层,有两个怪物
// hard_map 困难层,有三个怪物
// boss_map boss层,有一个boss
if newLayerId == 8 || newLayerId == 16 || newLayerId == 24 {
return "rest_map"
} else {
switch r.Int63() % 6 {
case 0:
if choice == "left" {
return "easy_map"
} else {
return "hard_map"
}
case 1:
if choice == "left" {
return "hard_map"
} else {
return "easy_map"
}
case 2:
if choice == "left" {
return "easy_map"
} else {
return "boss_map"
}
case 3:
if choice == "left" {
return "boss_map"
} else {
return "easy_map"
}
case 4:
if choice == "left" {
return "hard_map"
} else {
return "boss_map"
}
case 5:
if choice == "left" {
return "boss_map"
} else {
return "hard_map"
}
}
}
}
}
}
当时我手打的,我也看到过这段代码,但当时我看了半天,只做了一件事情,测试了一下选择"left"和选择“right”门,简单、困难和Boss地图的几率是不是一样的,我发现次数足够大时,是一样的,当时也就放弃了,其实本题的奥义就在此。
关键代码是这句:
r := rand.New(rand.NewSource(time.Now().UnixNano()))
首先随机种子在每一次new_level之后只取一次,后面都是用这个随机种子生成的rng去生成随机数,如果我们能知道这个随机数,那岂不是开了天眼,每次都选择easy map即可,那怎么才能知道这个随机数呢?
看一下Go的官方库rng随机数的实现:
// Seed uses the provided seed value to initialize the generator to a deterministic state.
func (rng *rngSource) Seed(seed int64) {
rng.tap = 0
rng.feed = rngLen - rngTap
seed = seed % int32max
if seed < 0 {
seed += int32max
}
if seed == 0 {
seed = 89482311
}
x := int32(seed)
for i := -20; i < rngLen; i++ {
x = seedrand(x)
if i >= 0 {
var u int64
u = int64(x) << 40
x = seedrand(x)
u ^= int64(x) << 20
x = seedrand(x)
u ^= int64(x)
u ^= rngCooked[i]
rng.vec[i] = u
}
}
}
敲黑板,注意这句seed = seed % int32max,int64的seed会进行截断,而后台服务器使用的是nanoseconds时间戳,也就是后半段表示nanoseconds的时间戳(如果是前半段时间戳,就结束了),官方给出的意思是可以通过枚举所有1~math.MaxInt32的候选种子,看看他们前几个随机的数字是多少,通过开宝箱、进入下一层,玩家是可以观察到,我开的是哪种类型的宝箱,下一层地图是哪种模式,比如我玩到第10层,观察到的结果是:
next Map_mode easy_map
BoxType add_hp_mp
next Map_mode hard_map
BoxType add_hp_mp
next Map_mode easy_map
BoxType add_skill
next Map_mode easy_map
BoxType del_hp
next Map_mode boss_map
BoxType add_hp_mp
next Map_mode boss_map
BoxType add_skill
next Map_mode hard_map
next Map_mode easy_map
BoxType del_hp
next Map_mode hard_map
地图类型和宝箱类型都能通过代码,推算出随机数取4或者6模是什么数值,我上面的序列对应序列是0111030221231021,这个自己想下,应该比较容易想明白吧。
那枚举所有1~math.MaxInt32,也通过一样的操作,得到可能的宝箱和地图类型序列,结果存在一个map中,key是宝箱和地图类型序列,value就是seed,用自己观察到的序列查询下map,既可以找到这个seed。
我试了下,如果要枚举所有的math.MaxInt32种子,还是很耗时的,当然完全通过并行计算,拆成30个并发任务,每个任务算个1亿多的种子,这也行,但是有参赛的极客想到了一个更好的策略,假设服务器的时间和客户端相差不大,在客户端记录下发送请求的nanosecond时间戳,然后加减10ms,去寻找这个时间戳,我自己试了下,前后10ms没有找到,然后我扩大范围,每扩大10ms,要多计算1000w的种子,我扩大到向后100ms时,激动人心的一刻到来了,我终于找到了:
1655533609434000000...
1655533609435000000...
1655533609436000000...
1655533609437000000...
1655533609438000000...
1655533609439000000...
1655533609440000000...
find key=[1655533609394757619]
其实,尽管在知道了反向查找随机种子之后,我还是花了非常久时间,大概有5~6个小时,才准确找到这个随机种子的,非常不容易,看来任何事情都是知易行难。但是尽管辛苦,我觉得很值得,我要求自己不仅要得到好的结果,也要用正确的方法得到好的结果,这样才是思考解决问题的正确方式吧。
整个极客赛过程,都是自己和自己较劲,我真的想了很多方法,包括一上来通过抓包,慢慢摸索游戏的规律,后来写bfs找每次击败怪兽的最优策略,到后面靠运气通关,然后再去思考怎么样通过破解随机种子开天眼,想想是不是一步步成长的过程,一步步通过对的思维和方法解决问题的过程。
哦,对了,这题充分说明,不要使用时间戳做随机种子,随机种子不要固定下来,嗯,否则是有可能让别人通过观察数据破解的,当然前提是对方猜测到了你的实现方式。
1
555
博主真是太厉害了!!!
不错不错,我喜欢看 https://www.237fa.com/
不错不错,我喜欢看 https://www.ea55.com/
《永恒生活》喜剧片高清在线免费观看:https://www.jgz518.com/xingkong/79930.html
《大唐双龙传之长生诀》国产剧高清在线免费观看:https://www.jgz518.com/xingkong/35328.html
文章的叙述风格独特,用词精准,让人回味无穷。
内容的丰富性和深度让人仿佛置身于知识的海洋,受益匪浅。
如星火燎原,既有锋芒又不失温度。
网络流行语融入自然,贴近年轻读者。
鬼娃回魂
真心话
死亡电压
命运理发师
不明影像绝对点击禁止
鸣禽
伟大遗产
谍徒迷局
古巴音缘
做了几十年的项目 我总结了最好的一个盘(纯干货)
2025年10月新盘 做第一批吃螃蟹的人coinsrore.com
新车新盘 嘎嘎稳 嘎嘎靠谱coinsrore.com
新车首发,新的一年,只带想赚米的人coinsrore.com
新盘 上车集合 留下 我要发发 立马进裙coinsrore.com
做了几十年的项目 我总结了最好的一个盘(纯干货)coinsrore.com
新车上路,只带前10个人coinsrore.com
新盘首开 新盘首开 征召客户!!!coinsrore.com
新项目准备上线,寻找志同道合 的合作伙伴coinsrore.com
新车即将上线 真正的项目,期待你的参与coinsrore.com
新盘新项目,不再等待,现在就是最佳上车机会!coinsrore.com
新盘新盘 这个月刚上新盘 新车第一个吃螃蟹!coinsrore.com