Quest Goal Data and Tracking
Where things live
| Concern | File |
|---|---|
| Quest goal definitions (data) | assets/data/quest_goal.cson |
| fcd payload generated from that data | assets/data/fcd/questgoal.json (via fcd/build.js) |
| Bundled/delivered merge and record re-sync | views/redux/info/quests/goals.ts |
QuestOptions (what an event dispatch carries) |
views/redux/actions/quest.ts |
QuestGoalSubgoal and the other engine types |
views/redux/info/quests/types.ts |
Matching helpers (satisfyGoal, satisfyShip) |
views/redux/info/quests/goal-matching.ts |
| Progress evaluation (where subgoal filters are applied) | views/redux/info/quests/records.ts |
| API responses -> quest events | views/redux/middlewares/quests-cross-slice.ts |
| fcd delivery -> quest goals | views/redux/middlewares/quest-goals-fcd.ts |
| Tests | views/redux/info/__tests__/quests.spec.ts, quest-goals-fcd.spec.ts |
The engine is a directory of focused modules (views/redux/info/quests/), not a single
quests.ts — it was split in commit 2fe7bf01.
Adding a new filter is a three-file change: a field on QuestOptions, a field on
QuestGoalSubgoal plus its check, and a dispatch in the middleware.
The data ships twice: bundled cson + fcd
assets/data/quest_goal.cson is the single source developers edit. It is bundled with the
build and is the fallback; fcd/build.js mirrors it into assets/data/fcd/questgoal.json
so a new or corrected quest reaches existing installs without a poi release.
After editing the cson, run node fcd/build.js and commit the regenerated
assets/data/fcd/questgoal.json and meta.json alongside it — npm test fails if you forget
(fcd/__tests__/payloads.spec.ts). The script resolves its inputs
from __dirname, so either working directory works. The build validates the cson first (every
id numeric, every quest has at least one subgoal, every subgoal a positive required), so a
broken edit fails there rather than shipping to everyone.
How the two combine at runtime (views/redux/info/quests/goals.ts):
- the bundled table is parsed once at
api_get_member/require_info; questGoalsFcdMiddlewarethen layers the delivered table over it, on@@updateFCD/@@replaceFCDand after everyrequire_info(either can come first, and require_info reloads the bundled table, so the merge has to be re-applied);- the merge is per quest id: a delivered quest replaces the bundled one wholesale (so fcd
can drop a subgoal or fix a
required), while ids the payload omits keep their bundled definition — an fcd copy cached before a quest existed must not blank it out. So fcd can correct a quest but never delete one; - a payload that is not strictly newer than the build is discarded entirely. The fcd slice is restored from
localStorage, so after an app update the copy cached by the previous release is still there,
and it carries nearly every quest id — per-id merging alone would let it shadow every bundled
correction.
mergeQuestGoalscompares the delivered version againstmeta.versionof the bundledassets/data/fcd/questgoal.json, which is generated from the same cson and so stands in for the bundled table's own version. (The updater in Settings → About does refresh the local payload at startup — Blueprint renders every settings panel, so that component is mounted even unseen — but the floor keeps the guarantee from resting on that.) Equal counts as "nothing new" on purpose: the bundled payload is generated from the bundled cson, so aquestgoal.jsonthat was not regenerated after a cson edit cannot shadow that edit. Thefcd/__tests__/payloads.spec.tstest catches that drift at the source, by comparing every committed payload against the file it is built from; resyncQuestRecordsthen carries existing progress across the change: a count survives arequiredcorrection (clamped to the new value), a dropped subgoal's record goes away, a new one starts atinit.
Adding a new subgoal filter field still needs a release — the payload only carries data, and
an old build silently ignores a field its satisfyShip/satisfyGoal does not know, which
makes that constraint vanish rather than fail. So data that will reach older builds has to keep
expressing the constraint in fields they already understand, or move to a new fcd name so those
builds keep their bundled copy.
type, and what the game's own fields mean
type exists only to drive record resets (outdateRecords in
views/redux/info/quests/records.ts) — nothing else reads it. Mapping from what the game
sends in questlist:
api_type |
api_label_type |
meaning | cson type |
|---|---|---|---|
| 1 | 2 | daily | 1 (8/9 for the two special dailies) |
| 2 | 3 | weekly | 2 |
| 3 | 6 | monthly | 3 |
| 5 | 7 | quarterly | 4 |
| 5 | 100+month | yearly | 101–112 |
| 4 | 1 | one-time (単発) | omit type |
A one-time quest must carry no type: a reset type deletes the record at the next period
boundary and throws away progress on a quest that is still open. resetInterval works without
a type, for a one-time quest whose counter is per-day.
What that means at a date change, for a type-less goal:
- its record is never touched by
outdateRecords—parseInt(String(undefined))isNaN, so it matches none of the delete sets (day/week/month/quarter, and each of the 12 yearly resets). WithresetInterval: 1only the counts go back toinit, the record stays; - its active-quest entry survives too, because
outdateActiveQuestskeys off the game'sapi_typein the quest detail (not the csontype), and one-time quests reportapi_type 4, which matches none of its expiry branches; - the record is removed when the quest is cleared (
api_req_quest/clearitemgetdrops both the record and the active entry).
Nothing else resets a count on a date change. The only other path that can lower one is
updateRecordProgress, which reconciles a single-subgoal record against the game's own
api_progress_flag on each questlist response (flag 0 caps it below 50% of required, and so
on). That is game-driven, not date-driven, and it skips multi-subgoal quests entirely.
The consequence is that a one-time record is never garbage-collected if the quest vanishes
without being cleared — a seasonal quest at the end of its season, say. That is deliberate: a
quest can sit outside the five active slots for weeks and must keep its count, so there is no
safe age at which to sweep it. views/redux/info/__tests__/quests.spec.ts covers both the
record and the active-quest side.
fcd/build.js asserts that each quest's type matches the section header it sits under
(Daily/Weekly/Monthly/Quarterly/Yearly (Month)/One-time), so a quest cannot sit in Weekly with a
monthly type — which is exactly how 242 reset its record on the wrong boundary for years. A
quest whose game period changes therefore has to move sections, not just change type.
A reset type also wins over resetInterval — at a quarter boundary a type: 4 record is
deleted outright rather than zeroed, and re-created from the goals on the next questlist
response. Covered by the outdateRecords tests in quests.spec.ts.
Subgoal filter fields worth knowing
slotitemId — filter destory_item by equipment master id
Filters scrapping events by a specific equipment id rather than only by equipment category
(slotitemType2). The middleware runs a second countBy loop grouping destroyed items by
api_slotitem_id and dispatches destory_item with { slotitemId: s } per id.
678:
fuzzy: true
"destory_item@Type 96 Fighter":
slotitemId: [19]
required: 3
"destory_item@Type 0 Fighter Model 21":
slotitemId: [20]
required: 5
materialShipType / materialShipMinCount — modernization material filter
Tracks which ship types were consumed as material in a modernization
(api_req_kaisou/powerup), for quests requiring >= 3 ships of a class as material.
The middleware builds a materialShipTypes array (one stype entry per material ship) and
dispatches exactly one event per modernization — no double counting:
{ event: 'remodel_ship', options: { times: 1, materialShipTypes: [3, 4, 3] }, delta: 1 }
The matcher counts how many of the dispatched stypes are in the subgoal's list and compares
against materialShipMinCount (default 3), so mixed-class material works (1 CL + 1 CLT +
1 training cruiser all count toward "light cruiser class"):
716:
"remodel_ship":
materialShipType: [3, 4, 21] # light cruiser class
materialShipMinCount: 3
required: 2
Quests 702/703 are unconstrained: they carry no times: [1] filter and simply match any
remodel_ship dispatch.
secondshipclass
Mirrors flagshipclass but checks shipclass[1] — the second ship's ctype. Added for quest 1045. Note flagship: ['吹雪改三'] substring-matches 改三護 too.
Ships are named by master id, expanded through the remodel line
flagshipId / secondshipId / escortshipId take master ship ids (api_mst_ship.api_id).
An id names where a ship starts counting: the middleware sends, per fleet ship, every id it
counts as — its own plus every id it can have been remodelled from (shipRemodelSources in
views/utils/selectors/base.ts) — and the matcher intersects that with the goal's ids. So:
[35](響) counts the ship in any state, renames included — 響改二 is 「Верный」 and shares no substring with 響;[233](潮改) counts 改 and later only, which is how 「〜改以降」 quests are expressed exactly;- nothing else creeps in: 満潮 is a different line, so it never counts toward 潮.
escortshipId entries are OR-ed, exactly as escortship's were: one entry is enough. That
is the common case — 903 takes 由良改二 ×1 or 睦月型 ×2. When a quest wants two groups
together, use escortshipIdAll, whose entries must all hold (like escortshiptype): 1051
asks for 扶桑/時雨 ×1 and 最上/満潮/朝雲/山雲 ×2, 875 for 長波改二 and one of
高波改/沖波改/朝霜改. Only those two quests need All; 45 subgoals carry a single entry, where
the distinction does not arise. Check which one a quest means against a guide — the two read
almost identically in the quest text. The third element of an entry still means "ignore the
flagship".
Because an id names a stage, a 改-or-later quest simply cannot accept the base ship: 94 of the
migrated goal ids name a remodelled stage, and none of them accepts an unremodelled ship. The
one nuance is switchable variants — [622] (夕張改二) also accepts 改二特 and 改二丁, which is
what quest 903 asks for anyway.
The remodel graph is not a plain chain — switchable variants (最上改二 ⇄ 最上改二特) form cycles and 24 ships are reachable from more than one remodel — so the expansion is a reachability walk with a seen set, not a pointer chase. A consequence of the cycles: for a switchable pair, either id also counts the other.
To find an id, look it up by exact name in an api_start2 capture (api_mst_ship, player ships
are api_id < 1500); see the redux-api-testing skill for where captures live.
The name fields (flagship, secondship, escortship) are deprecated. They match by
substring, which over-matches ('潮' also counted 満潮・大潮・荒潮・黒潮…) and silently misses
renamed remodels; escortship also OR-es its entries, which several quests had to exploit to
express an AND. All quest data was migrated to ids in the same change that added them; the
matcher still honours them, but do not add new ones.
Renamed remodels, for reading quest text and old data: 響→Верный, 雪風→丹陽, 大鯨→龍鳳, 春日丸→大鷹, 八幡丸→雲鷹, U-511→呂500, Littorio→Italia, Гангут→Октябрьская революция, Luigi Torelli→UIT-25/伊504, C.Cappellini→UIT-24/伊503, Phoenix→General Belgrano, Dace→Leonardo da Vinci, 南海→野埼. With ids none of these need special handling.
Nationality / class-based quests
Some quests select ships by nationality via name-substring arrays on flagship /
escortship, or by ctype via escortshipclass. These carry an
# extend when new <nation> ships are added comment — grep for those comments first when
told new ships shipped. Quest 373 (French practice) is the canonical example; quest 973 uses
an explicit 28-ctype US/UK list that must be extended when new US/UK classes are implemented.
mapcell
mapcell values are edge numbers into the boss node (kcanotify edges.json numbering), not
node ids. Verified against quest 928's 7-3-2 boss node.
Finding untracked quests
Collect api_no across all api_get_member/questlist/*.json response-saver captures (see the
redux-api-testing skill for the capture location), then subtract the keys matching
/^'?(\d+)'?:/m in assets/data/quest_goal.cson.
Expect arsenal (工廠) equipment-preparation quests to show up as untracked — that is by
design, not a gap: 626, 628, 637, 643, 645, 653, 654, 686, 1105, 1123, 1129, 1170.
Several of them (1170, for one) could have their scrap counters tracked, but only if
destory_item dispatches carried the first fleet's flagship, which the quest requires and
the event currently does not pass. Quest 637 has no
progress counter at all and is not trackable.
Composition-only quests (「…を編成せよ!」, e.g. 199) are not trackable at all: the engine
matches events, and organising a fleet is not one — see the QuestEvent union in
views/redux/actions/quest.ts.
Limited-time (期間限定) quests
These live in their own section at the end of quest_goal.cson. Two rules:
- Never add an expired one, and delete one whose period has ended. The game reuses limited-time ids for later campaigns, so a stale entry tracks the wrong quest.
- A capture proves a quest is live but never proves it is gone — a quest also disappears
from
questlistonce cleared (tab 0 lists only uncleared quests). So confirm the period against the maintenance notes (ととねこ / ぜかましねっと) rather than from absence alone; presence in a capture taken after the last maintenance is the reliable positive signal.
Tracked as of the 2026-09-10 maintenance: 382, 384, 1048, 1049. Deliberately not tracked from the same batch: 199 (composition only), 383 (フランス艦隊、特別演習 — period ended at that maintenance), 秋祭り拡張演習 (id not yet seen in any capture).
The resource-preparation half of a quest (「弾薬 x2,200 を準備」) carries no event and cannot be tracked; 1048/1049 track only their sortie subgoals, with a comment saying so.
Existing test coverage
views/redux/info/__tests__/quests.spec.ts already covers both filters — check it before
writing new cases:
destory_item counts by slotitemIdremodel_ship materialShipType combined count — passes when >= materialShipMinCount match