/
level.rb
462 lines (392 loc) · 13.1 KB
/
level.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# == Schema Information
#
# Table name: levels
#
# id :integer not null, primary key
# game_id :integer
# name :string(255) not null
# created_at :datetime
# updated_at :datetime
# level_num :string(255)
# ideal_level_source_id :integer
# solution_level_source_id :integer
# user_id :integer
# properties :text(65535)
# type :string(255)
# md5 :string(255)
# published :boolean default(FALSE), not null
# notes :text(65535)
# audit_log :text(65535)
#
# Indexes
#
# index_levels_on_game_id (game_id)
# index_levels_on_name (name)
#
class Level < ActiveRecord::Base
belongs_to :game
has_and_belongs_to_many :concepts
has_and_belongs_to_many :script_levels
belongs_to :ideal_level_source, class_name: "LevelSource" # "see the solution" link uses this
belongs_to :user
has_one :level_concept_difficulty, dependent: :destroy
has_many :level_sources
has_many :hint_view_requests
before_validation :strip_name
before_destroy :remove_empty_script_levels
validates_length_of :name, within: 1..70
validates_uniqueness_of :name, case_sensitive: false, conditions: -> {where.not(user_id: nil)}
after_save :write_custom_level_file
after_save :update_key_list
after_destroy :delete_custom_level_file
accepts_nested_attributes_for :level_concept_difficulty, update_only: true
include StiFactory
include SerializedProperties
include TextToSpeech
serialized_attrs %w(
video_key
embed
callout_json
instructions
markdown_instructions
authored_hints
instructions_important
display_name
)
# Fix STI routing http://stackoverflow.com/a/9463495
def self.model_name
self < Level ? Level.model_name : super
end
# https://github.com/rails/rails/issues/3508#issuecomment-29858772
# Include type in serialization.
def serializable_hash(options=nil)
super.merge 'type' => type
end
# Rails won't natively assign one-to-one association attributes for
# us, even though we've specified accepts_nested_attributes_for above.
# So, we must do it manually.
def assign_attributes(new_attributes)
attributes = new_attributes.stringify_keys
concept_difficulty_attributes = attributes.delete('level_concept_difficulty')
if concept_difficulty_attributes
assign_nested_attributes_for_one_to_one_association(
:level_concept_difficulty,
concept_difficulty_attributes
)
end
super(attributes)
end
def related_videos
([game.intro_video, specified_autoplay_video] + concepts.map(&:video)).reject(&:nil?).uniq
end
def specified_autoplay_video
@@specified_autoplay_video ||= {}
@@specified_autoplay_video[video_key] ||= Video.find_by_key(video_key) unless video_key.nil?
end
def self.key_list
@@all_level_keys ||= Level.all.map {|l| [l.id, l.key]}.to_h
@@all_level_keys
end
def update_key_list
@@all_level_keys ||= nil
@@all_level_keys[id] = key if @@all_level_keys
end
def summarize_concepts
concepts.pluck(:name).map {|c| "'#{c}'"}.join(', ')
end
def summarize_concept_difficulty
(level_concept_difficulty.try(:serializable_hash) || {}).to_json
end
def complete_toolbox(type)
"<xml id='toolbox' style='display: none;'>#{toolbox(type)}</xml>"
end
def host_level
project_template_level || self
end
# Overriden by different level types.
def toolbox(type)
end
def spelling_bee?
try(:skin) == 'letters'
end
def unplugged?
game && game.unplugged?
end
def finishable?
!unplugged?
end
def enable_scrolling?
is_a?(Blockly)
end
def enable_examples?
is_a?(Blockly)
end
# Overriden by different level types.
def self.start_directions
end
# Overriden by different level types.
def self.step_modes
end
# Overriden by different level types.
def self.flower_types
end
# Overriden by different level types.
def self.palette_categories
end
def self.custom_levels
Naturally.sort_by(Level.where.not(user_id: nil), :name)
end
# Custom levels are built in levelbuilder. Legacy levels are defined in .js.
# All custom levels will have a user_id, except for DSLDefined levels.
def custom?
user_id.present? || is_a?(DSLDefined)
end
def should_localize?
custom? && !I18n.en?
end
def available_callouts(script_level)
if custom?
unless callout_json.blank?
translations = I18n.t("data.callouts").
try(:[], "#{name}_callout".to_sym)
return JSON.parse(callout_json).map do |callout_definition|
callout_text = (should_localize? && translations.instance_of?(Hash)) ?
translations.try(:[], callout_definition['localization_key'].to_sym) :
callout_definition['callout_text']
Callout.new(
element_id: callout_definition['element_id'],
localization_key: callout_definition['localization_key'],
callout_text: callout_text,
qtip_config: callout_definition['qtip_config'].try(:to_json),
on: callout_definition['on']
)
end
end
elsif script_level
# Legacy levels have callouts associated with the ScriptLevel, not Level.
return script_level.callouts
end
[]
end
# Input: xml level file definition
# Output: Hash of level properties
def load_level_xml(xml_node)
JSON.parse(xml_node.xpath('//../config').first.text)
end
def self.write_custom_levels
level_paths = Dir.glob(Rails.root.join('config/scripts/**/*.level'))
written_level_paths = Level.custom_levels.map(&:write_custom_level_file)
(level_paths - written_level_paths).each {|path| File.delete path}
end
def should_write_custom_level_file?
changed = changed? || (level_concept_difficulty && level_concept_difficulty.changed?)
changed && write_to_file? && published
end
def write_custom_level_file
if should_write_custom_level_file?
file_path = LevelLoader.level_file_path(name)
File.write(file_path, to_xml)
file_path
end
end
def to_xml(options = {})
builder = Nokogiri::XML::Builder.new do |xml|
xml.send(type) do
xml.config do
hash = serializable_hash(include: :level_concept_difficulty).deep_dup
config_attributes = filter_level_attributes(hash)
xml.cdata(JSON.pretty_generate(config_attributes.as_json))
end
end
end
builder.to_xml(PRETTY_PRINT)
end
PRETTY_PRINT = {save_with: Nokogiri::XML::Node::SaveOptions::NO_DECLARATION | Nokogiri::XML::Node::SaveOptions::FORMAT}
def self.pretty_print_xml(xml_string)
xml = Nokogiri::XML(xml_string, &:noblanks)
xml.serialize(PRETTY_PRINT).strip
end
def filter_level_attributes(level_hash)
%w(name id updated_at type ideal_level_source_id md5).each {|field| level_hash.delete field}
level_hash.reject! {|_, v| v.nil?}
level_hash
end
def report_bug_url(request)
message = "Bug in Level #{name}\n#{request.url}\n#{request.user_agent}\n"
"https://support.code.org/hc/en-us/requests/new?&description=#{CGI.escape(message)}"
end
def delete_custom_level_file
if write_to_file?
file_path = Dir.glob(Rails.root.join("config/scripts/**/#{name}.level")).first
File.delete(file_path) if file_path && File.exist?(file_path)
end
end
TYPES_WITHOUT_IDEAL_LEVEL_SOURCE = [
'Applab', # freeplay
'ContractMatch', # dsl defined, covered in dsl
'CurriculumReference', # no user submitted content
'DSLDefined', # dsl defined, covered in dsl
'EvaluationMulti', # unknown
'EvaluationQuestion', # plc evaluation
'External', # dsl defined, covered in dsl
'ExternalLink', # no user submitted content
'FreeResponse', # no ideal solution
'FrequencyAnalysis', # widget
'Gamelab', # freeplay
'GoBeyond', # unknown
'Level', # base class
'LevelGroup', # dsl defined, covered in dsl
'Map', # no user submitted content
'Match', # dsl defined, covered in dsl
'Multi', # dsl defined, covered in dsl
'NetSim', # widget
'Pixelation', # widget
'PublicKeyCryptography', # widget
'Odometer', # widget
'ScriptCompletion', # unknown
'StandaloneVideo', # no user submitted content
'TextCompression', # widget
'TextMatch', # dsl defined, covered in dsl
'Unplugged', # no solutions
'Vigenere', # widget
'Weblab', # no ideal solution
'Widget', # widget
].freeze
TYPES_WITH_IDEAL_LEVEL_SOURCE = %w(
Artist
Blockly
Calc
Craft
Eval
Grid
Karel
Maze
Studio
StudioEC
)
def self.where_we_want_to_calculate_ideal_level_source
where('type not in (?)', TYPES_WITHOUT_IDEAL_LEVEL_SOURCE).
where('ideal_level_source_id is null').
to_a.reject {|level| level.try(:free_play)}
end
def calculate_ideal_level_source_id
ideal_level_source =
level_sources.
includes(:activities).
max_by {|level_source| level_source.activities.where("test_result >= #{Activity::FREE_PLAY_RESULT}").count}
update_attribute(:ideal_level_source_id, ideal_level_source.id) if ideal_level_source
end
def self.find_by_key(key)
# this is the key used in the script files, as a way to uniquely
# identify a level that can be defined by the .level file or in a
# blockly levels.js. for example, from hourofcode.script:
# level 'blockly:Maze:2_14'
# level 'scrat 16'
find_by(key_to_params(key))
end
def self.key_to_params(key)
if key.start_with?('blockly:')
_, game_name, level_num = key.split(':')
{game_id: Game.by_name(game_name), level_num: level_num}
else
{name: key}
end
end
# Returns whether this level is backed by a channel, whose id may
# be passed to the client, typically to save and load user progress
# on that level.
def channel_backed?
return false if try(:is_project_level)
free_response_upload = is_a?(FreeResponse) && allow_user_uploads
project_template_level || game == Game.applab || game == Game.gamelab || game == Game.weblab || game == Game.pixelation || free_response_upload
end
def key
if level_num == 'custom' || level_num.nil?
name
else
["blockly", game.name, level_num].join(':')
end
end
# Project template levels are used to persist use progress
# across multiple levels, using a single level name as the
# storage key for that user.
def project_template_level
return nil if try(:project_template_level_name).nil?
Level.find_by_key(project_template_level_name)
end
def strip_name
self.name = name.to_s.strip unless name.nil?
end
def log_changes(user=nil)
return unless changed?
log = JSON.parse(audit_log || "[]")
# gather all field changes; if the properties JSON blob is one of the things
# that changed, rather than including just 'properties' in the list, include
# all of those attributes within properties that changed.
latest_changes = changed.dup
if latest_changes.include?('properties') && changed_attributes['properties']
latest_changes.delete('properties')
changed_attributes['properties'].each do |key, value|
latest_changes.push(key) unless properties[key] == value
end
end
entry = {
changed_at: Time.now,
changed: latest_changes
}
unless user.nil?
entry[:changed_by_id] = user.id
entry[:changed_by_email] = user.email
end
log.push(entry)
# Because this ever-growing log is stored in a limited column and because we
# will tend to care a lot less about older entries than newer ones, we will
# here drop older entries until this log gets down to a reasonable size
log.shift while JSON.dump(log).length >= 65535
self.audit_log = JSON.dump(log)
end
def remove_empty_script_levels
script_levels.each do |script_level|
if script_level.levels.length == 1 && script_level.levels[0] == self
script_level.destroy
end
end
end
def self.cache_find(id)
Script.cache_find_level(id)
end
def icon
end
# Returns an array of all the contained levels
# (based on the contained_level_names property)
def contained_levels
names = properties["contained_level_names"]
return [] unless names.present?
properties["contained_level_names"].map do |contained_level_name|
Script.cache_find_level(contained_level_name)
end
end
def summary_for_lesson_plans
summary = {
level_id: id,
type: self.class.to_s,
name: name,
}
%w(title questions answers instructions markdown_instructions markdown teacher_markdown pages reference).each do |key|
value = properties[key] || try(key)
summary[key] = value if value
end
if video_key
summary[:video_youtube] = specified_autoplay_video.youtube_url
summary[:video_download] = specified_autoplay_video.download
end
unless contained_levels.empty?
summary[:contained_levels] = contained_levels.map(&:summary_for_lesson_plans)
end
summary
end
private
def write_to_file?
custom? && !is_a?(DSLDefined) && Rails.application.config.levelbuilder_mode
end
end